mirror of
https://github.com/Dvorinka/Trackeep.git
synced 2026-07-29 14:03:51 +00:00
Remove AI/chat/search features, add solidtime integration and workspace setup
This commit is contained in:
@@ -269,7 +269,7 @@ func AdminCreateUser(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
_ = ensureMessagingDefaults(db, user.ID)
|
||||
|
||||
|
||||
user.Password = ""
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
|
||||
@@ -1,811 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/trackeep/backend/models"
|
||||
"github.com/trackeep/backend/services"
|
||||
)
|
||||
|
||||
// SummarizeContentRequest represents a request to summarize content
|
||||
type SummarizeContentRequest struct {
|
||||
ContentType string `json:"content_type" binding:"required"` // "bookmark", "note", "file"
|
||||
ContentID uint `json:"content_id" binding:"required"`
|
||||
Provider string `json:"provider"` // "mistral", "longcat", "" for default
|
||||
ModelType string `json:"model_type"` // "standard", "thinking", "upgraded_thinking"
|
||||
Options struct {
|
||||
Length string `json:"length"` // "short", "medium", "long"
|
||||
Style string `json:"style"` // "bullet", "paragraph", "executive"
|
||||
IncludeKey bool `json:"include_key"` // Include key points
|
||||
} `json:"options"`
|
||||
}
|
||||
|
||||
// GenerateTaskSuggestionsRequest represents a request for task suggestions
|
||||
type GenerateTaskSuggestionsRequest struct {
|
||||
Context string `json:"context"` // "calendar", "deadlines", "habits", "all"
|
||||
Timeframe string `json:"timeframe"` // "today", "week", "month"
|
||||
Limit int `json:"limit"` // Max number of suggestions
|
||||
Provider string `json:"provider"` // "mistral", "longcat", "" for default
|
||||
ModelType string `json:"model_type"` // "standard", "thinking", "upgraded_thinking"
|
||||
}
|
||||
|
||||
// GenerateTagsRequest represents a request for tag suggestions
|
||||
type GenerateTagsRequest struct {
|
||||
ContentType string `json:"content_type" binding:"required"`
|
||||
ContentID uint `json:"content_id" binding:"required"`
|
||||
Content string `json:"content" binding:"required"`
|
||||
ExistingTag string `json:"existing_tags"`
|
||||
Provider string `json:"provider"` // "mistral", "longcat", "" for default
|
||||
ModelType string `json:"model_type"` // "standard", "thinking", "upgraded_thinking"
|
||||
}
|
||||
|
||||
// GenerateContentRequest represents a request for content generation
|
||||
type GenerateContentRequest struct {
|
||||
Prompt string `json:"prompt" binding:"required"`
|
||||
ContentType string `json:"content_type" binding:"required"`
|
||||
Context string `json:"context"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
MaxLength int `json:"max_length"`
|
||||
Provider string `json:"provider"` // "mistral", "longcat", "" for default
|
||||
ModelType string `json:"model_type"` // "standard", "thinking", "upgraded_thinking"
|
||||
}
|
||||
|
||||
// SummarizeContent generates AI summary for content
|
||||
func SummarizeContent(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
var req SummarizeContentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Get content based on type
|
||||
var content string
|
||||
var title string
|
||||
switch req.ContentType {
|
||||
case "bookmark":
|
||||
var bookmark models.Bookmark
|
||||
if err := models.DB.Where("id = ? AND user_id = ?", req.ContentID, userID).First(&bookmark).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Content not found"})
|
||||
return
|
||||
}
|
||||
content = bookmark.Content
|
||||
title = bookmark.Title
|
||||
case "note":
|
||||
var note models.Note
|
||||
if err := models.DB.Where("id = ? AND user_id = ?", req.ContentID, userID).First(¬e).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Content not found"})
|
||||
return
|
||||
}
|
||||
content = note.Content
|
||||
title = note.Title
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported content type"})
|
||||
return
|
||||
}
|
||||
|
||||
if content == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "No content to summarize"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if summary already exists
|
||||
var existingSummary models.AISummary
|
||||
if err := models.DB.Where("user_id = ? AND content_type = ? AND content_id = ?", userID, req.ContentType, req.ContentID).First(&existingSummary).Error; err == nil {
|
||||
c.JSON(http.StatusOK, existingSummary)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate summary using AI
|
||||
summary, err := generateAISummary(content, title, req.Options, req.Provider, req.ModelType)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate summary: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Save summary
|
||||
aiSummary := models.AISummary{
|
||||
UserID: userID,
|
||||
ContentType: req.ContentType,
|
||||
ContentID: req.ContentID,
|
||||
Title: summary.Title,
|
||||
Summary: summary.Summary,
|
||||
KeyPoints: summary.KeyPoints,
|
||||
Tags: summary.Tags,
|
||||
ReadTime: summary.ReadTime,
|
||||
Complexity: summary.Complexity,
|
||||
ModelUsed: getProviderModel(req.Provider),
|
||||
Confidence: summary.Confidence,
|
||||
LastAnalyzed: time.Now(),
|
||||
}
|
||||
|
||||
if err := models.DB.Create(&aiSummary).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save summary"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, aiSummary)
|
||||
}
|
||||
|
||||
// GetTaskSuggestions generates AI task suggestions
|
||||
func GetTaskSuggestions(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
var req GenerateTaskSuggestionsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Build context from user data
|
||||
contextData, err := buildTaskContext(userID, req.Context, req.Timeframe)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to build context"})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate suggestions
|
||||
suggestions, err := generateTaskSuggestions(contextData, req.Limit, req.Provider, req.ModelType)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate suggestions: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Save suggestions
|
||||
var aiSuggestions []models.AITaskSuggestion
|
||||
for _, suggestion := range suggestions {
|
||||
aiSuggestion := models.AITaskSuggestion{
|
||||
UserID: userID,
|
||||
Title: suggestion.Title,
|
||||
Description: suggestion.Description,
|
||||
Priority: suggestion.Priority,
|
||||
Category: suggestion.Category,
|
||||
Reasoning: suggestion.Reasoning,
|
||||
ContextType: req.Context,
|
||||
ContextData: suggestion.ContextData,
|
||||
Deadline: suggestion.Deadline,
|
||||
EstimatedTime: suggestion.EstimatedTime,
|
||||
ModelUsed: getProviderModel(req.Provider),
|
||||
Confidence: suggestion.Confidence,
|
||||
}
|
||||
models.DB.Create(&aiSuggestion)
|
||||
aiSuggestions = append(aiSuggestions, aiSuggestion)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, aiSuggestions)
|
||||
}
|
||||
|
||||
// GenerateTagSuggestions generates AI tag suggestions
|
||||
func GenerateTagSuggestions(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
var req GenerateTagsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate tags
|
||||
tags, err := generateTagSuggestions(req.Content, req.ExistingTag, req.Provider, req.ModelType)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate tags: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Save suggestion
|
||||
tagSuggestion := models.AITagSuggestion{
|
||||
UserID: userID,
|
||||
ContentType: req.ContentType,
|
||||
ContentID: req.ContentID,
|
||||
SuggestedTags: tags.Suggested,
|
||||
ExistingTags: req.ExistingTag,
|
||||
Relevance: tags.Relevance,
|
||||
ModelUsed: getProviderModel(req.Provider),
|
||||
Confidence: tags.Confidence,
|
||||
}
|
||||
|
||||
if err := models.DB.Create(&tagSuggestion).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save tag suggestion"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, tagSuggestion)
|
||||
}
|
||||
|
||||
// GenerateContent generates AI content
|
||||
func GenerateContent(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
var req GenerateContentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate content
|
||||
content, err := generateAIContent(req.Prompt, req.ContentType, req.Context, req.Temperature, req.MaxLength, req.Provider, req.ModelType)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate content: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Save generation
|
||||
aiContent := models.AIContentGeneration{
|
||||
UserID: userID,
|
||||
Prompt: req.Prompt,
|
||||
ContentType: req.ContentType,
|
||||
Context: req.Context,
|
||||
Title: content.Title,
|
||||
Content: content.Content,
|
||||
WordCount: content.WordCount,
|
||||
ReadTime: content.ReadTime,
|
||||
ModelUsed: getProviderModel(req.Provider),
|
||||
ProcessingMs: content.ProcessingMs,
|
||||
TokenCount: content.TokenCount,
|
||||
Confidence: content.Confidence,
|
||||
Temperature: req.Temperature,
|
||||
}
|
||||
|
||||
if err := models.DB.Create(&aiContent).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save content"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, aiContent)
|
||||
}
|
||||
|
||||
// GetAIProviders returns available AI providers
|
||||
func GetAIProviders(c *gin.Context) {
|
||||
providers := services.GetAvailableProviders()
|
||||
|
||||
providerInfo := make([]map[string]interface{}, 0)
|
||||
for _, provider := range providers {
|
||||
info := map[string]interface{}{
|
||||
"id": string(provider),
|
||||
"name": getProviderDisplayName(provider),
|
||||
}
|
||||
|
||||
// Add model info
|
||||
switch provider {
|
||||
case services.ProviderMistral:
|
||||
standardModel := os.Getenv("MISTRAL_MODEL")
|
||||
thinkingModel := os.Getenv("MISTRAL_MODEL_THINKING")
|
||||
|
||||
info["models"] = []map[string]string{
|
||||
{"id": "standard", "name": standardModel, "type": "Standard"},
|
||||
{"id": "thinking", "name": thinkingModel, "type": "Thinking"},
|
||||
}
|
||||
info["description"] = "Mistral AI - Fast and efficient European AI"
|
||||
info["icon"] = "🇪🇺"
|
||||
|
||||
case services.ProviderLongCat:
|
||||
standardModel := os.Getenv("LONGCAT_MODEL")
|
||||
thinkingModel := os.Getenv("LONGCAT_MODEL_THINKING")
|
||||
upgradedModel := os.Getenv("LONGCAT_MODEL_THINKING_UPGRADED")
|
||||
|
||||
models := []map[string]string{
|
||||
{"id": "standard", "name": standardModel, "type": "Standard"},
|
||||
{"id": "thinking", "name": thinkingModel, "type": "Thinking"},
|
||||
}
|
||||
|
||||
if upgradedModel != "" {
|
||||
models = append(models, map[string]string{"id": "upgraded_thinking", "name": upgradedModel, "type": "Upgraded Thinking"})
|
||||
}
|
||||
|
||||
info["models"] = models
|
||||
info["description"] = "LongCat AI - High-performance AI models"
|
||||
info["icon"] = "🐱"
|
||||
|
||||
case services.ProviderGrok:
|
||||
standardModel := os.Getenv("GROK_MODEL")
|
||||
thinkingModel := os.Getenv("GROK_MODEL_THINKING")
|
||||
|
||||
models := []map[string]string{
|
||||
{"id": "standard", "name": standardModel, "type": "Standard"},
|
||||
}
|
||||
|
||||
if thinkingModel != "" && thinkingModel != standardModel {
|
||||
models = append(models, map[string]string{"id": "thinking", "name": thinkingModel, "type": "Thinking"})
|
||||
}
|
||||
|
||||
info["models"] = models
|
||||
info["description"] = "Grok AI - Real-time information from X"
|
||||
info["icon"] = "🐦"
|
||||
|
||||
case services.ProviderDeepSeek:
|
||||
standardModel := os.Getenv("DEEPSEEK_MODEL")
|
||||
thinkingModel := os.Getenv("DEEPSEEK_MODEL_THINKING")
|
||||
|
||||
models := []map[string]string{
|
||||
{"id": "standard", "name": standardModel, "type": "Standard"},
|
||||
}
|
||||
|
||||
if thinkingModel != "" && thinkingModel != standardModel {
|
||||
models = append(models, map[string]string{"id": "thinking", "name": thinkingModel, "type": "Reasoning"})
|
||||
}
|
||||
|
||||
info["models"] = models
|
||||
info["description"] = "DeepSeek - Advanced reasoning AI"
|
||||
info["icon"] = "🔍"
|
||||
|
||||
case services.ProviderOllama:
|
||||
standardModel := os.Getenv("OLLAMA_MODEL")
|
||||
thinkingModel := os.Getenv("OLLAMA_MODEL_THINKING")
|
||||
|
||||
models := []map[string]string{
|
||||
{"id": "standard", "name": standardModel, "type": "Standard"},
|
||||
}
|
||||
|
||||
if thinkingModel != "" && thinkingModel != standardModel {
|
||||
models = append(models, map[string]string{"id": "thinking", "name": thinkingModel, "type": "Local"})
|
||||
}
|
||||
|
||||
info["models"] = models
|
||||
info["description"] = "Ollama - Local AI models"
|
||||
info["icon"] = "🦙"
|
||||
|
||||
case services.ProviderOpenRouter:
|
||||
standardModel := os.Getenv("OPENROUTER_MODEL")
|
||||
thinkingModel := os.Getenv("OPENROUTER_MODEL_THINKING")
|
||||
|
||||
models := []map[string]string{
|
||||
{"id": "standard", "name": standardModel, "type": "Standard"},
|
||||
}
|
||||
|
||||
if thinkingModel != "" && thinkingModel != standardModel {
|
||||
models = append(models, map[string]string{"id": "thinking", "name": thinkingModel, "type": "Thinking"})
|
||||
}
|
||||
|
||||
info["models"] = models
|
||||
info["description"] = "OpenRouter - Unified access to many models"
|
||||
info["icon"] = "🌀"
|
||||
}
|
||||
|
||||
providerInfo = append(providerInfo, info)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"providers": providerInfo})
|
||||
}
|
||||
|
||||
// Helper function to get display name for provider
|
||||
func getProviderDisplayName(provider services.AIProvider) string {
|
||||
switch provider {
|
||||
case services.ProviderMistral:
|
||||
return "Mistral AI"
|
||||
case services.ProviderLongCat:
|
||||
return "LongCat AI"
|
||||
case services.ProviderGrok:
|
||||
return "Grok AI"
|
||||
case services.ProviderDeepSeek:
|
||||
return "DeepSeek"
|
||||
case services.ProviderOllama:
|
||||
return "Ollama"
|
||||
case services.ProviderOpenRouter:
|
||||
return "OpenRouter"
|
||||
default:
|
||||
return string(provider)
|
||||
}
|
||||
}
|
||||
|
||||
// GetAISummaries retrieves AI summaries for user
|
||||
func GetAISummaries(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
var summaries []models.AISummary
|
||||
models.DB.Where("user_id = ?", userID).Order("created_at desc").Find(&summaries)
|
||||
|
||||
c.JSON(http.StatusOK, summaries)
|
||||
}
|
||||
|
||||
// GetTaskSuggestions retrieves task suggestions for user
|
||||
func GetTaskSuggestionsList(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
var suggestions []models.AITaskSuggestion
|
||||
models.DB.Where("user_id = ? AND accepted = false AND dismissed = false", userID).Order("created_at desc").Find(&suggestions)
|
||||
|
||||
c.JSON(http.StatusOK, suggestions)
|
||||
}
|
||||
|
||||
// AcceptTaskSuggestion accepts a task suggestion
|
||||
func AcceptTaskSuggestion(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
suggestionID := c.Param("id")
|
||||
|
||||
var suggestion models.AITaskSuggestion
|
||||
if err := models.DB.Where("id = ? AND user_id = ?", suggestionID, userID).First(&suggestion).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Suggestion not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Create actual task
|
||||
task := models.Task{
|
||||
UserID: userID,
|
||||
Title: suggestion.Title,
|
||||
Description: suggestion.Description,
|
||||
Priority: models.TaskPriority(suggestion.Priority),
|
||||
Status: models.TaskStatusPending,
|
||||
DueDate: suggestion.Deadline,
|
||||
}
|
||||
|
||||
if err := models.DB.Create(&task).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create task"})
|
||||
return
|
||||
}
|
||||
|
||||
// Mark suggestion as accepted
|
||||
suggestion.Accepted = true
|
||||
models.DB.Save(&suggestion)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Task created successfully", "task_id": task.ID})
|
||||
}
|
||||
|
||||
// DismissTaskSuggestion dismisses a task suggestion
|
||||
func DismissTaskSuggestion(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
suggestionID := c.Param("id")
|
||||
|
||||
var suggestion models.AITaskSuggestion
|
||||
if err := models.DB.Where("id = ? AND user_id = ?", suggestionID, userID).First(&suggestion).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Suggestion not found"})
|
||||
return
|
||||
}
|
||||
|
||||
suggestion.Dismissed = true
|
||||
models.DB.Save(&suggestion)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Suggestion dismissed"})
|
||||
}
|
||||
|
||||
// Helper structs for AI responses
|
||||
type AISummaryResponse struct {
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
KeyPoints string `json:"key_points"`
|
||||
Tags string `json:"tags"`
|
||||
ReadTime int `json:"read_time"`
|
||||
Complexity string `json:"complexity"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
}
|
||||
|
||||
type TaskSuggestionResponse struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Priority string `json:"priority"`
|
||||
Category string `json:"category"`
|
||||
Reasoning string `json:"reasoning"`
|
||||
ContextData string `json:"context_data"`
|
||||
Deadline *time.Time `json:"deadline"`
|
||||
EstimatedTime int `json:"estimated_time"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
}
|
||||
|
||||
type TagSuggestionResponse struct {
|
||||
Suggested string `json:"suggested"`
|
||||
Relevance float64 `json:"relevance"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
}
|
||||
|
||||
type ContentGenerationResponse struct {
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
WordCount int `json:"word_count"`
|
||||
ReadTime int `json:"read_time"`
|
||||
ProcessingMs int64 `json:"processing_ms"`
|
||||
TokenCount int `json:"token_count"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
}
|
||||
|
||||
// AI generation functions (simplified - would call actual AI models)
|
||||
func generateAISummary(content, title string, options struct {
|
||||
Length string `json:"length"`
|
||||
Style string `json:"style"`
|
||||
IncludeKey bool `json:"include_key"`
|
||||
}, provider string, modelType string) (*AISummaryResponse, error) {
|
||||
// Build prompt for summarization
|
||||
prompt := fmt.Sprintf(`Please summarize the following content:
|
||||
Title: %s
|
||||
Content: %s
|
||||
|
||||
Length: %s
|
||||
Style: %s
|
||||
Include key points: %t
|
||||
|
||||
Provide a JSON response with:
|
||||
- title: Brief title
|
||||
- summary: Main summary
|
||||
- key_points: Array of key points (if requested)
|
||||
- tags: Array of relevant tags
|
||||
- read_time: Estimated reading time in minutes
|
||||
- complexity: "low", "medium", or "high"
|
||||
- confidence: Confidence score 0-1`, title, content, options.Length, options.Style, options.IncludeKey)
|
||||
|
||||
messages := []services.Message{
|
||||
{Role: "system", Content: "You are an expert content summarizer. Always respond with valid JSON."},
|
||||
{Role: "user", Content: prompt},
|
||||
}
|
||||
|
||||
// Determine provider
|
||||
aiProvider := services.ProviderMistral // default
|
||||
if provider == "longcat" {
|
||||
aiProvider = services.ProviderLongCat
|
||||
}
|
||||
|
||||
aiService := services.NewAIService(aiProvider)
|
||||
|
||||
req := services.AIRequest{
|
||||
Messages: messages,
|
||||
MaxTokens: 2000,
|
||||
Temperature: 0.3,
|
||||
ModelType: modelType,
|
||||
}
|
||||
|
||||
var resp *services.AIResponse
|
||||
var err error
|
||||
|
||||
// Choose the appropriate method based on model type
|
||||
switch req.ModelType {
|
||||
case "thinking":
|
||||
resp, err = aiService.ChatCompletionWithThinking(req)
|
||||
case "upgraded_thinking":
|
||||
resp, err = aiService.ChatCompletionWithUpgradedThinking(req)
|
||||
default:
|
||||
resp, err = aiService.ChatCompletion(req)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse the response content properly for thinking models
|
||||
actualContent := services.ParseThinkingResponse(resp, aiProvider, modelType)
|
||||
|
||||
var summary AISummaryResponse
|
||||
if err := json.Unmarshal([]byte(actualContent), &summary); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &summary, nil
|
||||
}
|
||||
|
||||
func generateTaskSuggestions(contextData map[string]interface{}, limit int, provider string, modelType string) ([]TaskSuggestionResponse, error) {
|
||||
// Build prompt for task suggestions
|
||||
prompt := fmt.Sprintf(`Based on the following user context, suggest %d tasks:
|
||||
Context: %+v
|
||||
|
||||
Provide a JSON array of task objects with:
|
||||
- title: Task title
|
||||
- description: Task description
|
||||
- priority: "low", "medium", "high", "urgent"
|
||||
- category: Task category
|
||||
- reasoning: Why this task is suggested
|
||||
- context_data: Additional context
|
||||
- deadline: Suggested deadline (ISO date or null)
|
||||
- estimated_time: Estimated time in minutes
|
||||
- confidence: Confidence score 0-1`, limit, contextData)
|
||||
|
||||
messages := []services.Message{
|
||||
{Role: "system", Content: "You are a productivity assistant. Always respond with valid JSON array."},
|
||||
{Role: "user", Content: prompt},
|
||||
}
|
||||
|
||||
// Determine provider
|
||||
aiProvider := services.ProviderMistral // default
|
||||
if provider == "longcat" {
|
||||
aiProvider = services.ProviderLongCat
|
||||
}
|
||||
|
||||
aiService := services.NewAIService(aiProvider)
|
||||
|
||||
req := services.AIRequest{
|
||||
Messages: messages,
|
||||
MaxTokens: 2000,
|
||||
Temperature: 0.7,
|
||||
ModelType: modelType,
|
||||
}
|
||||
|
||||
var resp *services.AIResponse
|
||||
var err error
|
||||
|
||||
// Choose the appropriate method based on model type
|
||||
switch req.ModelType {
|
||||
case "thinking":
|
||||
resp, err = aiService.ChatCompletionWithThinking(req)
|
||||
case "upgraded_thinking":
|
||||
resp, err = aiService.ChatCompletionWithUpgradedThinking(req)
|
||||
default:
|
||||
resp, err = aiService.ChatCompletion(req)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse the response content properly for thinking models
|
||||
actualContent := services.ParseThinkingResponse(resp, aiProvider, modelType)
|
||||
|
||||
var suggestions []TaskSuggestionResponse
|
||||
if err := json.Unmarshal([]byte(actualContent), &suggestions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return suggestions, nil
|
||||
}
|
||||
|
||||
func generateTagSuggestions(content, existingTags string, provider string, modelType string) (*TagSuggestionResponse, error) {
|
||||
prompt := fmt.Sprintf(`Suggest relevant tags for this content:
|
||||
Content: %s
|
||||
Existing tags: %s
|
||||
|
||||
Provide JSON response with:
|
||||
- suggested: Array of suggested tags
|
||||
- relevance: Relevance score 0-1
|
||||
- confidence: Confidence score 0-1`, content, existingTags)
|
||||
|
||||
messages := []services.Message{
|
||||
{Role: "system", Content: "You are a tagging expert. Always respond with valid JSON."},
|
||||
{Role: "user", Content: prompt},
|
||||
}
|
||||
|
||||
// Determine provider
|
||||
aiProvider := services.ProviderMistral // default
|
||||
if provider == "longcat" {
|
||||
aiProvider = services.ProviderLongCat
|
||||
}
|
||||
|
||||
aiService := services.NewAIService(aiProvider)
|
||||
|
||||
req := services.AIRequest{
|
||||
Messages: messages,
|
||||
MaxTokens: 1000,
|
||||
Temperature: 0.5,
|
||||
ModelType: modelType,
|
||||
}
|
||||
|
||||
var resp *services.AIResponse
|
||||
var err error
|
||||
|
||||
// Choose the appropriate method based on model type
|
||||
switch req.ModelType {
|
||||
case "thinking":
|
||||
resp, err = aiService.ChatCompletionWithThinking(req)
|
||||
case "upgraded_thinking":
|
||||
resp, err = aiService.ChatCompletionWithUpgradedThinking(req)
|
||||
default:
|
||||
resp, err = aiService.ChatCompletion(req)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse the response content properly for thinking models
|
||||
actualContent := services.ParseThinkingResponse(resp, aiProvider, modelType)
|
||||
|
||||
var tags TagSuggestionResponse
|
||||
if err := json.Unmarshal([]byte(actualContent), &tags); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &tags, nil
|
||||
}
|
||||
|
||||
func generateAIContent(prompt, contentType, context string, temperature float64, maxLength int, provider string, modelType string) (*ContentGenerationResponse, error) {
|
||||
fullPrompt := fmt.Sprintf(`Generate %s content based on this prompt:
|
||||
%s
|
||||
Additional context: %s
|
||||
Max length: %d words
|
||||
|
||||
Provide JSON response with:
|
||||
- title: Generated title
|
||||
- content: Generated content
|
||||
- word_count: Word count
|
||||
- read_time: Estimated reading time in minutes
|
||||
- confidence: Confidence score 0-1`, contentType, prompt, context, maxLength)
|
||||
|
||||
messages := []services.Message{
|
||||
{Role: "system", Content: "You are a content generation expert. Always respond with valid JSON."},
|
||||
{Role: "user", Content: fullPrompt},
|
||||
}
|
||||
|
||||
// Determine provider
|
||||
aiProvider := services.ProviderMistral // default
|
||||
if provider == "longcat" {
|
||||
aiProvider = services.ProviderLongCat
|
||||
}
|
||||
|
||||
aiService := services.NewAIService(aiProvider)
|
||||
|
||||
// Adjust temperature if provided
|
||||
temp := 0.7
|
||||
if temperature > 0 {
|
||||
temp = temperature
|
||||
}
|
||||
|
||||
req := services.AIRequest{
|
||||
Messages: messages,
|
||||
MaxTokens: maxLength * 2, // Rough estimate
|
||||
Temperature: temp,
|
||||
ModelType: modelType,
|
||||
}
|
||||
|
||||
var resp *services.AIResponse
|
||||
var err error
|
||||
|
||||
// Choose the appropriate method based on model type
|
||||
switch req.ModelType {
|
||||
case "thinking":
|
||||
resp, err = aiService.ChatCompletionWithThinking(req)
|
||||
case "upgraded_thinking":
|
||||
resp, err = aiService.ChatCompletionWithUpgradedThinking(req)
|
||||
default:
|
||||
resp, err = aiService.ChatCompletion(req)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse the response content properly for thinking models
|
||||
actualContent := services.ParseThinkingResponse(resp, aiProvider, modelType)
|
||||
|
||||
var content ContentGenerationResponse
|
||||
if err := json.Unmarshal([]byte(actualContent), &content); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
content.ProcessingMs = 0 // Would track actual processing time
|
||||
content.TokenCount = resp.Usage.TotalTokens
|
||||
|
||||
return &content, nil
|
||||
}
|
||||
|
||||
func buildTaskContext(userID uint, contextType, timeframe string) (map[string]interface{}, error) {
|
||||
ctx := make(map[string]interface{})
|
||||
|
||||
// Get upcoming tasks
|
||||
var tasks []models.Task
|
||||
query := models.DB.Where("user_id = ?", userID)
|
||||
|
||||
if timeframe == "today" {
|
||||
query = query.Where("deadline <= ?", time.Now().AddDate(0, 0, 1))
|
||||
} else if timeframe == "week" {
|
||||
query = query.Where("deadline <= ?", time.Now().AddDate(0, 0, 7))
|
||||
}
|
||||
|
||||
query.Find(&tasks)
|
||||
ctx["tasks"] = tasks
|
||||
|
||||
// Get calendar events
|
||||
var events []models.CalendarEvent
|
||||
models.DB.Where("user_id = ? AND start_time >= ?", userID, time.Now()).Find(&events)
|
||||
ctx["events"] = events
|
||||
|
||||
return ctx, nil
|
||||
}
|
||||
|
||||
// Helper function to get model name based on provider
|
||||
func getProviderModel(provider string) string {
|
||||
switch provider {
|
||||
case "mistral":
|
||||
return os.Getenv("MISTRAL_MODEL")
|
||||
case "longcat":
|
||||
return os.Getenv("LONGCAT_MODEL")
|
||||
case "grok":
|
||||
return os.Getenv("GROK_MODEL")
|
||||
case "deepseek":
|
||||
return os.Getenv("DEEPSEEK_MODEL")
|
||||
case "ollama":
|
||||
return os.Getenv("OLLAMA_MODEL")
|
||||
case "openrouter":
|
||||
return os.Getenv("OPENROUTER_MODEL")
|
||||
default:
|
||||
return os.Getenv("MISTRAL_MODEL")
|
||||
}
|
||||
}
|
||||
@@ -1,401 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/trackeep/backend/models"
|
||||
"github.com/trackeep/backend/services"
|
||||
)
|
||||
|
||||
// AIRecommendationHandler handles AI recommendation endpoints
|
||||
type AIRecommendationHandler struct {
|
||||
db *gorm.DB
|
||||
service *services.AIRecommendationService
|
||||
}
|
||||
|
||||
// NewAIRecommendationHandler creates a new AI recommendation handler
|
||||
func NewAIRecommendationHandler(db *gorm.DB) *AIRecommendationHandler {
|
||||
return &AIRecommendationHandler{
|
||||
db: db,
|
||||
service: services.NewAIRecommendationService(db),
|
||||
}
|
||||
}
|
||||
|
||||
// GetRecommendations returns personalized recommendations for the user
|
||||
func (h *AIRecommendationHandler) GetRecommendations(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
// Parse query parameters
|
||||
recommendationType := c.DefaultQuery("type", "mixed") // content, task, learning, connection, mixed
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "5"))
|
||||
minConfidence, _ := strconv.ParseFloat(c.DefaultQuery("min_confidence", "0.0"), 64)
|
||||
includeDismissed := c.DefaultQuery("include_dismissed", "false") == "true"
|
||||
context := c.Query("context")
|
||||
|
||||
// Create recommendation request
|
||||
req := services.RecommendationRequest{
|
||||
UserID: userID,
|
||||
RecommendationType: recommendationType,
|
||||
Limit: limit,
|
||||
MinConfidence: minConfidence,
|
||||
IncludeDismissed: includeDismissed,
|
||||
Context: context,
|
||||
}
|
||||
|
||||
// Get recommendations
|
||||
recommendations, err := h.service.GetRecommendations(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get recommendations: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"recommendations": recommendations,
|
||||
"count": len(recommendations),
|
||||
"type": recommendationType,
|
||||
})
|
||||
}
|
||||
|
||||
// GetRecommendationStats returns recommendation statistics for the user
|
||||
func (h *AIRecommendationHandler) GetRecommendationStats(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
// Get user preferences
|
||||
var prefs models.UserPreference
|
||||
if err := h.db.Where("user_id = ?", userID).First(&prefs).Error; err != nil {
|
||||
// Create default preferences
|
||||
prefs = models.UserPreference{
|
||||
UserID: userID,
|
||||
EnableRecommendations: true,
|
||||
MinConfidenceThreshold: 0.6,
|
||||
MaxRecommendationsPerDay: 5,
|
||||
MaxAgeHours: 168,
|
||||
}
|
||||
h.db.Create(&prefs)
|
||||
}
|
||||
|
||||
// Get recommendation statistics
|
||||
var stats struct {
|
||||
TotalRecommendations int64 `json:"total_recommendations"`
|
||||
ClickedCount int64 `json:"clicked_count"`
|
||||
DismissedCount int64 `json:"dismissed_count"`
|
||||
FeedbackCount int64 `json:"feedback_count"`
|
||||
Types []struct {
|
||||
Type string `json:"type"`
|
||||
Count int64 `json:"count"`
|
||||
} `json:"types"`
|
||||
Categories []struct {
|
||||
Category string `json:"category"`
|
||||
Count int64 `json:"count"`
|
||||
} `json:"categories"`
|
||||
DailyStats []struct {
|
||||
Date string `json:"date"`
|
||||
Count int64 `json:"count"`
|
||||
} `json:"daily_stats"`
|
||||
}
|
||||
|
||||
// Total recommendations
|
||||
h.db.Model(&models.AIRecommendation{}).Where("user_id = ?", userID).Count(&stats.TotalRecommendations)
|
||||
|
||||
// Clicked and dismissed counts
|
||||
h.db.Model(&models.AIRecommendation{}).Where("user_id = ? AND clicked = ?", userID, true).Count(&stats.ClickedCount)
|
||||
h.db.Model(&models.AIRecommendation{}).Where("user_id = ? AND dismissed = ?", userID, true).Count(&stats.DismissedCount)
|
||||
h.db.Model(&models.AIRecommendation{}).Where("user_id = ? AND feedback != ''", userID).Count(&stats.FeedbackCount)
|
||||
|
||||
// Recommendations by type
|
||||
h.db.Model(&models.AIRecommendation{}).
|
||||
Select("recommendation_type as type, COUNT(*) as count").
|
||||
Where("user_id = ?", userID).
|
||||
Group("recommendation_type").
|
||||
Scan(&stats.Types)
|
||||
|
||||
// Recommendations by category
|
||||
h.db.Model(&models.AIRecommendation{}).
|
||||
Select("category as category, COUNT(*) as count").
|
||||
Where("user_id = ? AND category != ''", userID).
|
||||
Group("category").
|
||||
Order("count DESC").
|
||||
Limit(10).
|
||||
Scan(&stats.Categories)
|
||||
|
||||
// Daily stats for last 30 days
|
||||
h.db.Model(&models.AIRecommendation{}).
|
||||
Select("DATE(created_at) as date, COUNT(*) as count").
|
||||
Where("user_id = ? AND created_at >= NOW() - INTERVAL '30 days'", userID).
|
||||
Group("DATE(created_at)").
|
||||
Order("date ASC").
|
||||
Scan(&stats.DailyStats)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"stats": stats,
|
||||
"preferences": prefs,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdatePreferences updates user recommendation preferences
|
||||
func (h *AIRecommendationHandler) UpdatePreferences(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
var req struct {
|
||||
EnableRecommendations bool `json:"enable_recommendations"`
|
||||
ContentRecommendations bool `json:"content_recommendations"`
|
||||
TaskRecommendations bool `json:"task_recommendations"`
|
||||
LearningRecommendations bool `json:"learning_recommendations"`
|
||||
ConnectionRecommendations bool `json:"connection_recommendations"`
|
||||
MaxRecommendationsPerDay int `json:"max_recommendations_per_day"`
|
||||
PreferredCategories []string `json:"preferred_categories"`
|
||||
BlockedCategories []string `json:"blocked_categories"`
|
||||
PreferredContentTypes []string `json:"preferred_content_types"`
|
||||
MinConfidenceThreshold float64 `json:"min_confidence_threshold"`
|
||||
MaxAgeHours int `json:"max_age_hours"`
|
||||
EnablePersonalization bool `json:"enable_personalization"`
|
||||
EnableFeedbackLearning bool `json:"enable_feedback_learning"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Update or create preferences
|
||||
var prefs models.UserPreference
|
||||
if err := h.db.Where("user_id = ?", userID).First(&prefs).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
prefs = models.UserPreference{UserID: userID}
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Update fields
|
||||
prefs.EnableRecommendations = req.EnableRecommendations
|
||||
prefs.ContentRecommendations = req.ContentRecommendations
|
||||
prefs.TaskRecommendations = req.TaskRecommendations
|
||||
prefs.LearningRecommendations = req.LearningRecommendations
|
||||
prefs.ConnectionRecommendations = req.ConnectionRecommendations
|
||||
prefs.MaxRecommendationsPerDay = req.MaxRecommendationsPerDay
|
||||
prefs.PreferredCategories = req.PreferredCategories
|
||||
prefs.BlockedCategories = req.BlockedCategories
|
||||
prefs.PreferredContentTypes = req.PreferredContentTypes
|
||||
prefs.MinConfidenceThreshold = req.MinConfidenceThreshold
|
||||
prefs.MaxAgeHours = req.MaxAgeHours
|
||||
prefs.EnablePersonalization = req.EnablePersonalization
|
||||
prefs.EnableFeedbackLearning = req.EnableFeedbackLearning
|
||||
|
||||
if err := h.db.Save(&prefs).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update preferences"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Preferences updated successfully",
|
||||
"preferences": prefs,
|
||||
})
|
||||
}
|
||||
|
||||
// RecordInteraction records user interaction with a recommendation
|
||||
func (h *AIRecommendationHandler) RecordInteraction(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
recommendationIDStr := c.Param("id")
|
||||
|
||||
recommendationID, err := strconv.ParseUint(recommendationIDStr, 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid recommendation ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
InteractionType string `json:"interaction_type" binding:"required"` // click, dismiss, feedback, share
|
||||
Context string `json:"context"` // dashboard, search, etc.
|
||||
Feedback string `json:"feedback"` // helpful, not_helpful, irrelevant
|
||||
FeedbackText string `json:"feedback_text"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Record the interaction
|
||||
if err := h.service.RecordInteraction(userID, uint(recommendationID), req.InteractionType, req.Context); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to record interaction"})
|
||||
return
|
||||
}
|
||||
|
||||
// If feedback is provided, update the recommendation
|
||||
if req.Feedback != "" {
|
||||
var recommendation models.AIRecommendation
|
||||
if err := h.db.Where("id = ? AND user_id = ?", uint(recommendationID), userID).First(&recommendation).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Recommendation not found"})
|
||||
return
|
||||
}
|
||||
|
||||
recommendation.Feedback = req.Feedback
|
||||
recommendation.FeedbackText = req.FeedbackText
|
||||
now := time.Now()
|
||||
recommendation.FeedbackAt = &now
|
||||
|
||||
h.db.Save(&recommendation)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Interaction recorded successfully"})
|
||||
}
|
||||
|
||||
// GetRecommendationHistory returns user's recommendation history
|
||||
func (h *AIRecommendationHandler) GetRecommendationHistory(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
// Parse query parameters
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
recommendationType := c.Query("type")
|
||||
status := c.Query("status") // clicked, dismissed, feedback
|
||||
|
||||
// Build query
|
||||
query := h.db.Model(&models.AIRecommendation{}).Where("user_id = ?", userID)
|
||||
|
||||
if recommendationType != "" {
|
||||
query = query.Where("recommendation_type = ?", recommendationType)
|
||||
}
|
||||
|
||||
if status == "clicked" {
|
||||
query = query.Where("clicked = ?", true)
|
||||
} else if status == "dismissed" {
|
||||
query = query.Where("dismissed = ?", true)
|
||||
} else if status == "feedback" {
|
||||
query = query.Where("feedback != ''", userID)
|
||||
}
|
||||
|
||||
// Count total records
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
// Get paginated results
|
||||
offset := (page - 1) * limit
|
||||
var recommendations []models.AIRecommendation
|
||||
query.Order("created_at DESC").Limit(limit).Offset(offset).Find(&recommendations)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"recommendations": recommendations,
|
||||
"pagination": gin.H{
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total": total,
|
||||
"pages": (total + int64(limit) - 1) / int64(limit),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteRecommendation deletes a recommendation
|
||||
func (h *AIRecommendationHandler) DeleteRecommendation(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
recommendationIDStr := c.Param("id")
|
||||
|
||||
recommendationID, err := strconv.ParseUint(recommendationIDStr, 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid recommendation ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// Delete the recommendation (only if it belongs to the user)
|
||||
result := h.db.Where("id = ? AND user_id = ?", uint(recommendationID), userID).Delete(&models.AIRecommendation{})
|
||||
|
||||
if result.RowsAffected == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Recommendation not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Recommendation deleted successfully"})
|
||||
}
|
||||
|
||||
// GetInsights returns AI insights about user patterns
|
||||
func (h *AIRecommendationHandler) GetInsights(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
var insights struct {
|
||||
TopInterests []string `json:"top_interests"`
|
||||
LearningPaths []string `json:"learning_paths"`
|
||||
ProductivityTips []string `json:"productivity_tips"`
|
||||
ConnectionSuggestions []string `json:"connection_suggestions"`
|
||||
Patterns struct {
|
||||
BestProductivityHours []string `json:"best_productivity_hours"`
|
||||
PreferredContentTypes []string `json:"preferred_content_types"`
|
||||
LearningStyle string `json:"learning_style"`
|
||||
} `json:"patterns"`
|
||||
}
|
||||
|
||||
// Get user's top interests from bookmarks and tags
|
||||
var interests []struct {
|
||||
Tag string `json:"tag"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
h.db.Raw(`
|
||||
SELECT unnest(string_to_array(tags, ',')) as tag, COUNT(*) as count
|
||||
FROM bookmarks
|
||||
WHERE user_id = ? AND tags != ''
|
||||
GROUP BY tag
|
||||
ORDER BY count DESC
|
||||
LIMIT 10
|
||||
`, userID).Scan(&interests)
|
||||
|
||||
for _, interest := range interests {
|
||||
insights.TopInterests = append(insights.TopInterests, interest.Tag)
|
||||
}
|
||||
|
||||
// Get learning path suggestions
|
||||
var learningPaths []struct {
|
||||
Category string `json:"category"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
h.db.Raw(`
|
||||
SELECT lp.category, COUNT(*) as count
|
||||
FROM learning_paths lp
|
||||
JOIN enrollments e ON lp.id = e.learning_path_id
|
||||
WHERE e.user_id = ? AND e.progress < 100
|
||||
GROUP BY lp.category
|
||||
ORDER BY count DESC
|
||||
LIMIT 5
|
||||
`, userID).Scan(&learningPaths)
|
||||
|
||||
for _, path := range learningPaths {
|
||||
insights.LearningPaths = append(insights.LearningPaths, path.Category)
|
||||
}
|
||||
|
||||
// Generate productivity tips based on task patterns
|
||||
insights.ProductivityTips = []string{
|
||||
"You complete most tasks in the morning - consider scheduling important work before noon",
|
||||
"Tasks with deadlines are completed 80% faster - set more deadlines",
|
||||
"You're most productive on Tuesdays and Wednesdays",
|
||||
}
|
||||
|
||||
// Generate connection suggestions
|
||||
topInterest := "technology"
|
||||
if len(insights.TopInterests) > 0 {
|
||||
topInterest = insights.TopInterests[0]
|
||||
}
|
||||
|
||||
learningFocus := "productivity"
|
||||
if len(insights.LearningPaths) > 0 {
|
||||
learningFocus = insights.LearningPaths[0]
|
||||
}
|
||||
|
||||
insights.ConnectionSuggestions = []string{
|
||||
"Connect with users who share your interest in " + topInterest,
|
||||
"Join communities focused on " + learningFocus,
|
||||
}
|
||||
|
||||
// Analyze patterns
|
||||
insights.Patterns.BestProductivityHours = []string{"9:00 AM - 11:00 AM", "2:00 PM - 4:00 PM"}
|
||||
insights.Patterns.PreferredContentTypes = []string{"bookmarks", "notes", "courses"}
|
||||
insights.Patterns.LearningStyle = "Visual learner who prefers structured content"
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"insights": insights})
|
||||
}
|
||||
@@ -1,388 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/trackeep/backend/models"
|
||||
)
|
||||
|
||||
// AISettings represents AI provider settings
|
||||
type AISettings struct {
|
||||
Mistral struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKey string `json:"api_key"`
|
||||
Model string `json:"model"`
|
||||
ModelThink string `json:"model_thinking"`
|
||||
} `json:"mistral"`
|
||||
|
||||
Grok struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKey string `json:"api_key"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Model string `json:"model"`
|
||||
ModelThink string `json:"model_thinking"`
|
||||
} `json:"grok"`
|
||||
|
||||
DeepSeek struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKey string `json:"api_key"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Model string `json:"model"`
|
||||
ModelThink string `json:"model_thinking"`
|
||||
} `json:"deepseek"`
|
||||
|
||||
Ollama struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Model string `json:"model"`
|
||||
ModelThink string `json:"model_thinking"`
|
||||
} `json:"ollama"`
|
||||
|
||||
LongCat struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKey string `json:"api_key"`
|
||||
BaseURL string `json:"base_url"`
|
||||
OpenAIEndpoint string `json:"openai_endpoint"`
|
||||
AnthropicEndpoint string `json:"anthropic_endpoint"`
|
||||
Model string `json:"model"`
|
||||
ModelThink string `json:"model_thinking"`
|
||||
ModelThinkUpgraded string `json:"model_thinking_upgraded"`
|
||||
Format string `json:"format"`
|
||||
} `json:"longcat"`
|
||||
|
||||
OpenRouter struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKey string `json:"api_key"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Model string `json:"model"`
|
||||
ModelThink string `json:"model_thinking"`
|
||||
} `json:"openrouter"`
|
||||
}
|
||||
|
||||
// GetAISettings returns current AI settings (with API keys masked)
|
||||
func GetAISettings(c *gin.Context) {
|
||||
// Return settings based on environment variables
|
||||
settings := getDefaultAISettings()
|
||||
c.JSON(http.StatusOK, settings)
|
||||
}
|
||||
|
||||
// UpdateAISettings updates user's AI settings
|
||||
func UpdateAISettings(c *gin.Context) {
|
||||
// Check if demo mode is enabled
|
||||
if os.Getenv("VITE_DEMO_MODE") == "true" {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "AI settings updated successfully (demo mode)"})
|
||||
return
|
||||
}
|
||||
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
var req AISettings
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Get or create user settings
|
||||
var userSettings models.UserAISettings
|
||||
if err := models.DB.Where("user_id = ?", userID).First(&userSettings).Error; err != nil {
|
||||
// Create new settings
|
||||
userSettings.UserID = userID
|
||||
}
|
||||
|
||||
// Update settings
|
||||
userSettings.MistralEnabled = &req.Mistral.Enabled
|
||||
if req.Mistral.APIKey != "" && !isMasked(req.Mistral.APIKey) {
|
||||
userSettings.MistralAPIKey = req.Mistral.APIKey
|
||||
}
|
||||
userSettings.MistralModel = req.Mistral.Model
|
||||
userSettings.MistralModelThinking = req.Mistral.ModelThink
|
||||
|
||||
userSettings.GrokEnabled = &req.Grok.Enabled
|
||||
if req.Grok.APIKey != "" && !isMasked(req.Grok.APIKey) {
|
||||
userSettings.GrokAPIKey = req.Grok.APIKey
|
||||
}
|
||||
userSettings.GrokBaseURL = req.Grok.BaseURL
|
||||
userSettings.GrokModel = req.Grok.Model
|
||||
userSettings.GrokModelThinking = req.Grok.ModelThink
|
||||
|
||||
userSettings.DeepSeekEnabled = &req.DeepSeek.Enabled
|
||||
if req.DeepSeek.APIKey != "" && !isMasked(req.DeepSeek.APIKey) {
|
||||
userSettings.DeepSeekAPIKey = req.DeepSeek.APIKey
|
||||
}
|
||||
userSettings.DeepSeekBaseURL = req.DeepSeek.BaseURL
|
||||
userSettings.DeepSeekModel = req.DeepSeek.Model
|
||||
userSettings.DeepSeekModelThinking = req.DeepSeek.ModelThink
|
||||
|
||||
userSettings.OllamaEnabled = &req.Ollama.Enabled
|
||||
userSettings.OllamaBaseURL = req.Ollama.BaseURL
|
||||
userSettings.OllamaModel = req.Ollama.Model
|
||||
userSettings.OllamaModelThinking = req.Ollama.ModelThink
|
||||
|
||||
userSettings.LongCatEnabled = &req.LongCat.Enabled
|
||||
if req.LongCat.APIKey != "" && !isMasked(req.LongCat.APIKey) {
|
||||
userSettings.LongCatAPIKey = req.LongCat.APIKey
|
||||
}
|
||||
userSettings.LongCatBaseURL = req.LongCat.BaseURL
|
||||
userSettings.LongCatOpenAIEndpoint = req.LongCat.OpenAIEndpoint
|
||||
userSettings.LongCatAnthropicEndpoint = req.LongCat.AnthropicEndpoint
|
||||
userSettings.LongCatModel = req.LongCat.Model
|
||||
userSettings.LongCatModelThinking = req.LongCat.ModelThink
|
||||
userSettings.LongCatModelThinkingUpgraded = req.LongCat.ModelThinkUpgraded
|
||||
userSettings.LongCatFormat = req.LongCat.Format
|
||||
|
||||
userSettings.OpenRouterEnabled = &req.OpenRouter.Enabled
|
||||
if req.OpenRouter.APIKey != "" && !isMasked(req.OpenRouter.APIKey) {
|
||||
userSettings.OpenRouterAPIKey = req.OpenRouter.APIKey
|
||||
}
|
||||
userSettings.OpenRouterBaseURL = req.OpenRouter.BaseURL
|
||||
userSettings.OpenRouterModel = req.OpenRouter.Model
|
||||
userSettings.OpenRouterModelThinking = req.OpenRouter.ModelThink
|
||||
|
||||
// Save to database
|
||||
if userSettings.ID == 0 {
|
||||
if err := models.DB.Create(&userSettings).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save settings"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if err := models.DB.Save(&userSettings).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update settings"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "AI settings updated successfully"})
|
||||
}
|
||||
|
||||
// TestAIConnection tests connection to AI provider
|
||||
func TestAIConnection(c *gin.Context) {
|
||||
// Check if demo mode is enabled
|
||||
if os.Getenv("VITE_DEMO_MODE") == "true" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Connection test successful (demo mode)",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
provider := c.Query("provider")
|
||||
if provider == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Provider is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get user's settings for this provider
|
||||
var userSettings models.UserAISettings
|
||||
if err := models.DB.Where("user_id = ?", userID).First(&userSettings).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "AI settings not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Test connection based on provider
|
||||
var success bool
|
||||
var message string
|
||||
|
||||
switch provider {
|
||||
case "mistral":
|
||||
if userSettings.MistralAPIKey == "" {
|
||||
success = false
|
||||
message = "Mistral API key not configured"
|
||||
} else {
|
||||
// TODO: Implement actual connection test
|
||||
success = true
|
||||
message = "Mistral connection test successful"
|
||||
}
|
||||
case "grok":
|
||||
if userSettings.GrokAPIKey == "" {
|
||||
success = false
|
||||
message = "Grok API key not configured"
|
||||
} else {
|
||||
// TODO: Implement actual connection test
|
||||
success = true
|
||||
message = "Grok connection test successful"
|
||||
}
|
||||
case "deepseek":
|
||||
if userSettings.DeepSeekAPIKey == "" {
|
||||
success = false
|
||||
message = "DeepSeek API key not configured"
|
||||
} else {
|
||||
// TODO: Implement actual connection test
|
||||
success = true
|
||||
message = "DeepSeek connection test successful"
|
||||
}
|
||||
case "longcat":
|
||||
if userSettings.LongCatAPIKey == "" {
|
||||
success = false
|
||||
message = "LongCat API key not configured"
|
||||
} else {
|
||||
// TODO: Implement actual connection test
|
||||
success = true
|
||||
message = "LongCat connection test successful"
|
||||
}
|
||||
case "ollama":
|
||||
if userSettings.OllamaBaseURL == "" {
|
||||
success = false
|
||||
message = "Ollama base URL not configured"
|
||||
} else {
|
||||
// TODO: Implement actual connection test
|
||||
success = true
|
||||
message = "Ollama connection test successful"
|
||||
}
|
||||
case "openrouter":
|
||||
if userSettings.OpenRouterAPIKey == "" {
|
||||
success = false
|
||||
message = "OpenRouter API key not configured"
|
||||
} else {
|
||||
// TODO: Implement actual connection test
|
||||
success = true
|
||||
message = "OpenRouter connection test successful"
|
||||
}
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Unknown provider"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": success,
|
||||
"message": message,
|
||||
})
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
func getDefaultAISettings() AISettings {
|
||||
settings := AISettings{}
|
||||
|
||||
// Simple approach - just set basic values without any complex logic
|
||||
settings.Mistral.Enabled = false
|
||||
settings.Mistral.APIKey = ""
|
||||
settings.Mistral.Model = ""
|
||||
settings.Mistral.ModelThink = ""
|
||||
|
||||
settings.Grok.Enabled = false
|
||||
settings.Grok.APIKey = ""
|
||||
settings.Grok.BaseURL = ""
|
||||
settings.Grok.Model = ""
|
||||
settings.Grok.ModelThink = ""
|
||||
|
||||
settings.DeepSeek.Enabled = false
|
||||
settings.DeepSeek.APIKey = ""
|
||||
settings.DeepSeek.BaseURL = ""
|
||||
settings.DeepSeek.Model = ""
|
||||
settings.DeepSeek.ModelThink = ""
|
||||
|
||||
settings.Ollama.Enabled = false
|
||||
settings.Ollama.BaseURL = ""
|
||||
settings.Ollama.Model = ""
|
||||
settings.Ollama.ModelThink = ""
|
||||
|
||||
settings.LongCat.Enabled = false
|
||||
settings.LongCat.APIKey = ""
|
||||
settings.LongCat.BaseURL = ""
|
||||
settings.LongCat.OpenAIEndpoint = ""
|
||||
settings.LongCat.AnthropicEndpoint = ""
|
||||
settings.LongCat.Model = ""
|
||||
settings.LongCat.ModelThink = ""
|
||||
settings.LongCat.ModelThinkUpgraded = ""
|
||||
settings.LongCat.Format = ""
|
||||
|
||||
settings.OpenRouter.Enabled = false
|
||||
settings.OpenRouter.APIKey = ""
|
||||
settings.OpenRouter.BaseURL = ""
|
||||
settings.OpenRouter.Model = ""
|
||||
settings.OpenRouter.ModelThink = ""
|
||||
|
||||
// Read environment variables to determine enabled providers
|
||||
// This works in both demo and production mode
|
||||
if os.Getenv("MISTRAL_ON") == "true" {
|
||||
settings.Mistral.Enabled = true
|
||||
}
|
||||
if os.Getenv("MISTRAL_API_KEY") != "" {
|
||||
settings.Mistral.APIKey = "********"
|
||||
}
|
||||
settings.Mistral.Model = os.Getenv("MISTRAL_MODEL")
|
||||
settings.Mistral.ModelThink = os.Getenv("MISTRAL_MODEL_THINKING")
|
||||
|
||||
if os.Getenv("LONGCAT_ON") == "true" {
|
||||
settings.LongCat.Enabled = true
|
||||
}
|
||||
if os.Getenv("LONGCAT_API_KEY") != "" {
|
||||
settings.LongCat.APIKey = "********"
|
||||
}
|
||||
settings.LongCat.BaseURL = os.Getenv("LONGCAT_BASE_URL")
|
||||
settings.LongCat.OpenAIEndpoint = os.Getenv("LONGCAT_OPENAI_ENDPOINT")
|
||||
settings.LongCat.AnthropicEndpoint = os.Getenv("LONGCAT_ANTHROPIC_ENDPOINT")
|
||||
settings.LongCat.Model = os.Getenv("LONGCAT_MODEL")
|
||||
settings.LongCat.ModelThink = os.Getenv("LONGCAT_MODEL_THINKING")
|
||||
settings.LongCat.ModelThinkUpgraded = os.Getenv("LONGCAT_MODEL_THINKING_UPGRADED")
|
||||
settings.LongCat.Format = os.Getenv("LONGCAT_FORMAT")
|
||||
|
||||
if os.Getenv("GROK_ON") == "true" {
|
||||
settings.Grok.Enabled = true
|
||||
}
|
||||
if os.Getenv("GROK_API_KEY") != "" {
|
||||
settings.Grok.APIKey = "********"
|
||||
}
|
||||
settings.Grok.BaseURL = os.Getenv("GROK_BASE_URL")
|
||||
settings.Grok.Model = os.Getenv("GROK_MODEL")
|
||||
settings.Grok.ModelThink = os.Getenv("GROK_MODEL_THINKING")
|
||||
|
||||
if os.Getenv("DEEPSEEK_ON") == "true" {
|
||||
settings.DeepSeek.Enabled = true
|
||||
}
|
||||
if os.Getenv("DEEPSEEK_API_KEY") != "" {
|
||||
settings.DeepSeek.APIKey = "********"
|
||||
}
|
||||
settings.DeepSeek.BaseURL = os.Getenv("DEEPSEEK_BASE_URL")
|
||||
settings.DeepSeek.Model = os.Getenv("DEEPSEEK_MODEL")
|
||||
settings.DeepSeek.ModelThink = os.Getenv("DEEPSEEK_MODEL_THINKING")
|
||||
|
||||
if os.Getenv("OLLAMA_ON") == "true" {
|
||||
settings.Ollama.Enabled = true
|
||||
}
|
||||
settings.Ollama.BaseURL = os.Getenv("OLLAMA_BASE_URL")
|
||||
settings.Ollama.Model = os.Getenv("OLLAMA_MODEL")
|
||||
settings.Ollama.ModelThink = os.Getenv("OLLAMA_MODEL_THINKING")
|
||||
|
||||
if os.Getenv("OPENROUTER_ON") == "true" {
|
||||
settings.OpenRouter.Enabled = true
|
||||
}
|
||||
if os.Getenv("OPENROUTER_API_KEY") != "" {
|
||||
settings.OpenRouter.APIKey = "********"
|
||||
}
|
||||
settings.OpenRouter.BaseURL = os.Getenv("OPENROUTER_BASE_URL")
|
||||
settings.OpenRouter.Model = os.Getenv("OPENROUTER_MODEL")
|
||||
settings.OpenRouter.ModelThink = os.Getenv("OPENROUTER_MODEL_THINKING")
|
||||
|
||||
return settings
|
||||
}
|
||||
|
||||
func maskAPIKey(key string) string {
|
||||
if key == "" {
|
||||
return ""
|
||||
}
|
||||
if len(key) <= 8 {
|
||||
return "********"
|
||||
}
|
||||
return key[:4] + "********" + key[len(key)-4:]
|
||||
}
|
||||
|
||||
func isMasked(key string) bool {
|
||||
return key == "" || (len(key) > 8 && key[4:12] == "********")
|
||||
}
|
||||
|
||||
func getBoolEnv(key string, defaultValue bool) bool {
|
||||
value := os.Getenv(key)
|
||||
if value == "" {
|
||||
return defaultValue
|
||||
}
|
||||
boolValue, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return defaultValue
|
||||
}
|
||||
return boolValue
|
||||
}
|
||||
@@ -392,7 +392,7 @@ func Register(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Provision messaging defaults (self chat, password vault, global channels).
|
||||
_ = ensureMessagingDefaults(db, user.ID)
|
||||
|
||||
|
||||
// Generate JWT token
|
||||
token, err := GenerateJWT(user)
|
||||
|
||||
@@ -1,360 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/trackeep/backend/models"
|
||||
)
|
||||
|
||||
// UserContext represents the contextual data available to the AI
|
||||
type UserContext struct {
|
||||
Bookmarks []models.Bookmark
|
||||
Tasks []models.Task
|
||||
Files []models.File
|
||||
Notes []models.Note
|
||||
}
|
||||
|
||||
// buildUserContext gathers user data based on session configuration
|
||||
func buildUserContext(userID uint, session models.ChatSession) (*UserContext, error) {
|
||||
context := &UserContext{}
|
||||
|
||||
// Get bookmarks
|
||||
if session.IncludeBookmarks {
|
||||
var bookmarks []models.Bookmark
|
||||
models.DB.Where("user_id = ?", userID).Limit(20).Order("updated_at desc").Find(&bookmarks)
|
||||
context.Bookmarks = bookmarks
|
||||
}
|
||||
|
||||
// Get tasks
|
||||
if session.IncludeTasks {
|
||||
var tasks []models.Task
|
||||
models.DB.Where("user_id = ?", userID).Limit(20).Order("updated_at desc").Find(&tasks)
|
||||
context.Tasks = tasks
|
||||
}
|
||||
|
||||
// Get files
|
||||
if session.IncludeFiles {
|
||||
var files []models.File
|
||||
models.DB.Where("user_id = ?", userID).Limit(20).Order("updated_at desc").Find(&files)
|
||||
context.Files = files
|
||||
}
|
||||
|
||||
// Get notes
|
||||
if session.IncludeNotes {
|
||||
var notes []models.Note
|
||||
models.DB.Where("user_id = ?", userID).Limit(20).Order("updated_at desc").Find(¬es)
|
||||
context.Notes = notes
|
||||
}
|
||||
|
||||
return context, nil
|
||||
}
|
||||
|
||||
// buildSystemPrompt creates a system prompt with user context
|
||||
func buildSystemPrompt(context *UserContext) string {
|
||||
prompt := `You are a helpful AI assistant for Trackeep, a personal productivity and knowledge management platform.
|
||||
You have access to the user's personal data including bookmarks, tasks, files, and notes.
|
||||
Your role is to help them organize, find information, and manage their digital life effectively.
|
||||
|
||||
Key capabilities:
|
||||
- Help find specific bookmarks, tasks, or notes
|
||||
- Suggest organization strategies
|
||||
- Answer questions about their saved content
|
||||
- Help with task planning and prioritization
|
||||
- Assist with learning progress tracking
|
||||
|
||||
Be helpful, concise, and actionable. If you reference specific items, mention their titles or key details.
|
||||
|
||||
--- USER DATA ---`
|
||||
|
||||
// Add bookmarks context
|
||||
if len(context.Bookmarks) > 0 {
|
||||
prompt += "\n\nBOOKMARKS:\n"
|
||||
for i, bookmark := range context.Bookmarks {
|
||||
if i >= 10 { // Limit to prevent token overflow
|
||||
prompt += "... and " + strconv.Itoa(len(context.Bookmarks)-10) + " more bookmarks\n"
|
||||
break
|
||||
}
|
||||
prompt += fmt.Sprintf("- %s: %s", bookmark.Title, bookmark.URL)
|
||||
if bookmark.Description != "" {
|
||||
prompt += " (" + bookmark.Description + ")"
|
||||
}
|
||||
if bookmark.IsFavorite {
|
||||
prompt += " ⭐"
|
||||
}
|
||||
prompt += "\n"
|
||||
}
|
||||
}
|
||||
|
||||
// Add tasks context
|
||||
if len(context.Tasks) > 0 {
|
||||
prompt += "\n\nTASKS:\n"
|
||||
for i, task := range context.Tasks {
|
||||
if i >= 10 {
|
||||
prompt += "... and " + strconv.Itoa(len(context.Tasks)-10) + " more tasks\n"
|
||||
break
|
||||
}
|
||||
status := string(task.Status)
|
||||
priority := string(task.Priority)
|
||||
prompt += fmt.Sprintf("- [%s] %s (Priority: %s)", status, task.Title, priority)
|
||||
if task.DueDate != nil {
|
||||
prompt += " Due: " + task.DueDate.Format("Jan 2")
|
||||
}
|
||||
prompt += "\n"
|
||||
}
|
||||
}
|
||||
|
||||
// Add files context
|
||||
if len(context.Files) > 0 {
|
||||
prompt += "\n\nFILES:\n"
|
||||
for i, file := range context.Files {
|
||||
if i >= 10 {
|
||||
prompt += "... and " + strconv.Itoa(len(context.Files)-10) + " more files\n"
|
||||
break
|
||||
}
|
||||
prompt += fmt.Sprintf("- %s (%s, %s)", file.OriginalName, file.FileType, formatFileSize(file.FileSize))
|
||||
if file.Description != "" {
|
||||
prompt += " - " + file.Description
|
||||
}
|
||||
prompt += "\n"
|
||||
}
|
||||
}
|
||||
|
||||
// Add notes context
|
||||
if len(context.Notes) > 0 {
|
||||
prompt += "\n\nNOTES:\n"
|
||||
for i, note := range context.Notes {
|
||||
if i >= 10 {
|
||||
prompt += "... and " + strconv.Itoa(len(context.Notes)-10) + " more notes\n"
|
||||
break
|
||||
}
|
||||
prompt += fmt.Sprintf("- %s", note.Title)
|
||||
if note.Description != "" {
|
||||
prompt += " - " + note.Description
|
||||
}
|
||||
if note.IsPinned {
|
||||
prompt += " 📌"
|
||||
}
|
||||
prompt += "\n"
|
||||
}
|
||||
}
|
||||
|
||||
prompt += "\n--- END USER DATA ---\n\nNow respond to the user's message based on this context."
|
||||
return prompt
|
||||
}
|
||||
|
||||
// getContextItemIDs extracts IDs from context for tracking
|
||||
func getContextItemIDs(context *UserContext) []string {
|
||||
var ids []string
|
||||
|
||||
for _, bookmark := range context.Bookmarks {
|
||||
ids = append(ids, "bookmark:"+strconv.Itoa(int(bookmark.ID)))
|
||||
}
|
||||
|
||||
for _, task := range context.Tasks {
|
||||
ids = append(ids, "task:"+strconv.Itoa(int(task.ID)))
|
||||
}
|
||||
|
||||
for _, file := range context.Files {
|
||||
ids = append(ids, "file:"+strconv.Itoa(int(file.ID)))
|
||||
}
|
||||
|
||||
for _, note := range context.Notes {
|
||||
ids = append(ids, "note:"+strconv.Itoa(int(note.ID)))
|
||||
}
|
||||
|
||||
return ids
|
||||
}
|
||||
|
||||
// formatFileSize formats file size in human readable format
|
||||
func formatFileSize(bytes int64) string {
|
||||
const unit = 1024
|
||||
if bytes < unit {
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for n := bytes / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -268,7 +268,7 @@ func upsertCentralizedOAuthUser(db *gorm.DB, controllerUser centralizedOAuthUser
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_ = ensureMessagingDefaults(db, user.ID)
|
||||
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
@@ -1,662 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/trackeep/backend/models"
|
||||
)
|
||||
|
||||
// SavedSearchRequest represents the request payload for creating/updating saved searches
|
||||
type SavedSearchRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Query string `json:"query" binding:"required"`
|
||||
Filters map[string]interface{} `json:"filters"`
|
||||
Alert bool `json:"alert"`
|
||||
IsPublic bool `json:"is_public"`
|
||||
Description string `json:"description"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
// SavedSearchResponse represents the response payload for saved searches
|
||||
type SavedSearchResponse struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Query string `json:"query"`
|
||||
Filters map[string]interface{} `json:"filters"`
|
||||
Alert bool `json:"alert"`
|
||||
LastRun *time.Time `json:"last_run"`
|
||||
RunCount int `json:"run_count"`
|
||||
IsPublic bool `json:"is_public"`
|
||||
Description string `json:"description"`
|
||||
Tags []models.SavedSearchTag `json:"tags"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// CreateSavedSearch handles POST /api/v1/search/saved
|
||||
func CreateSavedSearch(c *gin.Context) {
|
||||
var req SavedSearchRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
userID := c.GetUint("user_id")
|
||||
if userID == 0 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
// Serialize filters to JSON
|
||||
filtersJSON, err := json.Marshal(req.Filters)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid filters format"})
|
||||
return
|
||||
}
|
||||
|
||||
// Create saved search
|
||||
savedSearch := models.SavedSearch{
|
||||
UserID: userID,
|
||||
Name: req.Name,
|
||||
Query: req.Query,
|
||||
Filters: string(filtersJSON),
|
||||
Alert: req.Alert,
|
||||
IsPublic: req.IsPublic,
|
||||
RunCount: 0,
|
||||
Tags: []models.SavedSearchTag{},
|
||||
}
|
||||
|
||||
// Handle tags
|
||||
if len(req.Tags) > 0 {
|
||||
db := c.MustGet("db").(*gorm.DB)
|
||||
for _, tagName := range req.Tags {
|
||||
var tag models.SavedSearchTag
|
||||
if err := db.Where("name = ?", tagName).First(&tag).Error; err != nil {
|
||||
// Create new tag if it doesn't exist
|
||||
tag = models.SavedSearchTag{
|
||||
Name: tagName,
|
||||
Color: "#3b82f6", // Default blue color
|
||||
}
|
||||
db.Create(&tag)
|
||||
}
|
||||
savedSearch.Tags = append(savedSearch.Tags, tag)
|
||||
}
|
||||
}
|
||||
|
||||
db := c.MustGet("db").(*gorm.DB)
|
||||
if err := db.Create(&savedSearch).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create saved search"})
|
||||
return
|
||||
}
|
||||
|
||||
// Load tags for response
|
||||
db.Preload("Tags").First(&savedSearch, savedSearch.ID)
|
||||
|
||||
response := SavedSearchResponse{
|
||||
ID: savedSearch.ID,
|
||||
Name: savedSearch.Name,
|
||||
Query: savedSearch.Query,
|
||||
Alert: savedSearch.Alert,
|
||||
LastRun: savedSearch.LastRun,
|
||||
RunCount: savedSearch.RunCount,
|
||||
IsPublic: savedSearch.IsPublic,
|
||||
Description: savedSearch.Description,
|
||||
Tags: savedSearch.Tags,
|
||||
CreatedAt: savedSearch.CreatedAt,
|
||||
UpdatedAt: savedSearch.UpdatedAt,
|
||||
}
|
||||
|
||||
// Parse filters back to map
|
||||
json.Unmarshal([]byte(savedSearch.Filters), &response.Filters)
|
||||
|
||||
c.JSON(http.StatusCreated, response)
|
||||
}
|
||||
|
||||
// GetUserSavedSearches handles GET /api/v1/search/saved
|
||||
func GetUserSavedSearches(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
if userID == 0 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
db := c.MustGet("db").(*gorm.DB)
|
||||
|
||||
// Parse query parameters
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
tagFilter := c.Query("tag")
|
||||
alertFilter := c.Query("alert")
|
||||
|
||||
offset := (page - 1) * limit
|
||||
|
||||
query := db.Model(&models.SavedSearch{}).Where("user_id = ? OR is_public = ?", userID, true)
|
||||
|
||||
// Apply filters
|
||||
if tagFilter != "" {
|
||||
query = query.Joins("JOIN saved_search_tags ON saved_search_tags.id = saved_searches.id").
|
||||
Joins("JOIN saved_search_tag_saved_searches ON saved_search_tag_saved_searches.saved_search_id = saved_searches.id").
|
||||
Joins("JOIN saved_search_tags t ON t.id = saved_search_tag_saved_searches.saved_search_tag_id").
|
||||
Where("t.name = ?", tagFilter)
|
||||
}
|
||||
|
||||
if alertFilter == "true" {
|
||||
query = query.Where("alert = ?", true)
|
||||
} else if alertFilter == "false" {
|
||||
query = query.Where("alert = ?", false)
|
||||
}
|
||||
|
||||
var savedSearches []models.SavedSearch
|
||||
var total int64
|
||||
|
||||
if err := query.Preload("Tags").Count(&total).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to count saved searches"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := query.Preload("Tags").Offset(offset).Limit(limit).Order("created_at DESC").Find(&savedSearches).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch saved searches"})
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to response format
|
||||
var responses []SavedSearchResponse
|
||||
for _, ss := range savedSearches {
|
||||
var filters map[string]interface{}
|
||||
json.Unmarshal([]byte(ss.Filters), &filters)
|
||||
|
||||
response := SavedSearchResponse{
|
||||
ID: ss.ID,
|
||||
Name: ss.Name,
|
||||
Query: ss.Query,
|
||||
Filters: filters,
|
||||
Alert: ss.Alert,
|
||||
LastRun: ss.LastRun,
|
||||
RunCount: ss.RunCount,
|
||||
IsPublic: ss.IsPublic,
|
||||
Description: ss.Description,
|
||||
Tags: ss.Tags,
|
||||
CreatedAt: ss.CreatedAt,
|
||||
UpdatedAt: ss.UpdatedAt,
|
||||
}
|
||||
responses = append(responses, response)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"saved_searches": responses,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
})
|
||||
}
|
||||
|
||||
// GetSavedSearch handles GET /api/v1/search/saved/:id
|
||||
func GetSavedSearch(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
if userID == 0 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid saved search ID"})
|
||||
return
|
||||
}
|
||||
|
||||
db := c.MustGet("db").(*gorm.DB)
|
||||
var savedSearch models.SavedSearch
|
||||
|
||||
if err := db.Preload("Tags").Where("id = ? AND (user_id = ? OR is_public = ?)", id, userID, true).First(&savedSearch).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Saved search not found"})
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch saved search"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var filters map[string]interface{}
|
||||
json.Unmarshal([]byte(savedSearch.Filters), &filters)
|
||||
|
||||
response := SavedSearchResponse{
|
||||
ID: savedSearch.ID,
|
||||
Name: savedSearch.Name,
|
||||
Query: savedSearch.Query,
|
||||
Filters: filters,
|
||||
Alert: savedSearch.Alert,
|
||||
LastRun: savedSearch.LastRun,
|
||||
RunCount: savedSearch.RunCount,
|
||||
IsPublic: savedSearch.IsPublic,
|
||||
Description: savedSearch.Description,
|
||||
Tags: savedSearch.Tags,
|
||||
CreatedAt: savedSearch.CreatedAt,
|
||||
UpdatedAt: savedSearch.UpdatedAt,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// UpdateSavedSearch handles PUT /api/v1/search/saved/:id
|
||||
func UpdateSavedSearch(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
if userID == 0 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid saved search ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req SavedSearchRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
db := c.MustGet("db").(*gorm.DB)
|
||||
var savedSearch models.SavedSearch
|
||||
|
||||
if err := db.Where("id = ? AND user_id = ?", id, userID).First(&savedSearch).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Saved search not found"})
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch saved search"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Update fields
|
||||
savedSearch.Name = req.Name
|
||||
savedSearch.Query = req.Query
|
||||
savedSearch.Alert = req.Alert
|
||||
savedSearch.IsPublic = req.IsPublic
|
||||
savedSearch.Description = req.Description
|
||||
|
||||
// Update filters
|
||||
filtersJSON, err := json.Marshal(req.Filters)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid filters format"})
|
||||
return
|
||||
}
|
||||
savedSearch.Filters = string(filtersJSON)
|
||||
|
||||
// Update tags
|
||||
if err := db.Model(&savedSearch).Association("Tags").Clear(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clear tags"})
|
||||
return
|
||||
}
|
||||
|
||||
for _, tagName := range req.Tags {
|
||||
var tag models.SavedSearchTag
|
||||
if err := db.Where("name = ?", tagName).First(&tag).Error; err != nil {
|
||||
tag = models.SavedSearchTag{
|
||||
Name: tagName,
|
||||
Color: "#3b82f6",
|
||||
}
|
||||
db.Create(&tag)
|
||||
}
|
||||
if err := db.Model(&savedSearch).Association("Tags").Append(&tag); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add tag"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.Save(&savedSearch).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update saved search"})
|
||||
return
|
||||
}
|
||||
|
||||
// Load updated data
|
||||
db.Preload("Tags").First(&savedSearch, savedSearch.ID)
|
||||
|
||||
var filters map[string]interface{}
|
||||
json.Unmarshal([]byte(savedSearch.Filters), &filters)
|
||||
|
||||
response := SavedSearchResponse{
|
||||
ID: savedSearch.ID,
|
||||
Name: savedSearch.Name,
|
||||
Query: savedSearch.Query,
|
||||
Filters: filters,
|
||||
Alert: savedSearch.Alert,
|
||||
LastRun: savedSearch.LastRun,
|
||||
RunCount: savedSearch.RunCount,
|
||||
IsPublic: savedSearch.IsPublic,
|
||||
Description: savedSearch.Description,
|
||||
Tags: savedSearch.Tags,
|
||||
CreatedAt: savedSearch.CreatedAt,
|
||||
UpdatedAt: savedSearch.UpdatedAt,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// DeleteSavedSearch handles DELETE /api/v1/search/saved/:id
|
||||
func DeleteSavedSearch(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
if userID == 0 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid saved search ID"})
|
||||
return
|
||||
}
|
||||
|
||||
db := c.MustGet("db").(*gorm.DB)
|
||||
result := db.Where("id = ? AND user_id = ?", id, userID).Delete(&models.SavedSearch{})
|
||||
|
||||
if result.Error != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete saved search"})
|
||||
return
|
||||
}
|
||||
|
||||
if result.RowsAffected == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Saved search not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Saved search deleted successfully"})
|
||||
}
|
||||
|
||||
// RunSavedSearch handles POST /api/v1/search/saved/:id/run
|
||||
func RunSavedSearch(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
if userID == 0 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid saved search ID"})
|
||||
return
|
||||
}
|
||||
|
||||
db := c.MustGet("db").(*gorm.DB)
|
||||
var savedSearch models.SavedSearch
|
||||
|
||||
if err := db.Where("id = ? AND (user_id = ? OR is_public = ?)", id, userID, true).First(&savedSearch).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Saved search not found"})
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch saved search"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Parse filters
|
||||
var filters map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(savedSearch.Filters), &filters); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to parse filters"})
|
||||
return
|
||||
}
|
||||
|
||||
// Create search request based on saved search
|
||||
searchReq := map[string]interface{}{
|
||||
"query": savedSearch.Query,
|
||||
}
|
||||
|
||||
// Merge filters
|
||||
for k, v := range filters {
|
||||
searchReq[k] = v
|
||||
}
|
||||
|
||||
// Perform the search using existing enhanced search logic
|
||||
// This is a simplified version - in production, you'd want to reuse the actual search handler
|
||||
searchResults, err := performSearchFromSavedSearch(searchReq, userID, db)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to execute search"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update saved search run statistics
|
||||
now := time.Now()
|
||||
savedSearch.LastRun = &now
|
||||
savedSearch.RunCount++
|
||||
db.Save(&savedSearch)
|
||||
|
||||
// Log search analytics
|
||||
logSearchAnalytics(userID, savedSearch.Query, savedSearch.Filters, len(searchResults), db)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"results": searchResults,
|
||||
"query": savedSearch.Query,
|
||||
"filters": filters,
|
||||
"total": len(searchResults),
|
||||
"saved_search": gin.H{
|
||||
"id": savedSearch.ID,
|
||||
"name": savedSearch.Name,
|
||||
"last_run": savedSearch.LastRun,
|
||||
"run_count": savedSearch.RunCount,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetSavedSearchTags handles GET /api/v1/search/saved/tags
|
||||
func GetSavedSearchTags(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
if userID == 0 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
db := c.MustGet("db").(*gorm.DB)
|
||||
var tags []models.SavedSearchTag
|
||||
|
||||
if err := db.Order("name").Find(&tags).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch tags"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"tags": tags})
|
||||
}
|
||||
|
||||
// Helper function to perform search from saved search
|
||||
func performSearchFromSavedSearch(searchReq map[string]interface{}, userID uint, db *gorm.DB) ([]interface{}, error) {
|
||||
// Build search filters from the request
|
||||
filters := SearchFilters{
|
||||
Query: getStringValue(searchReq, "query"),
|
||||
ContentType: getStringValue(searchReq, "content_type"),
|
||||
Limit: getIntValue(searchReq, "limit", 20),
|
||||
Offset: getIntValue(searchReq, "offset", 0),
|
||||
}
|
||||
|
||||
// Parse tags if present
|
||||
if tags, ok := searchReq["tags"].([]interface{}); ok {
|
||||
for _, tag := range tags {
|
||||
if tagStr, ok := tag.(string); ok {
|
||||
filters.Tags = append(filters.Tags, tagStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse date range if present
|
||||
if dateRange, ok := searchReq["date_range"].(map[string]interface{}); ok {
|
||||
if startStr, ok := dateRange["start"].(string); ok && startStr != "" {
|
||||
if startTime, err := time.Parse("2006-01-02", startStr); err == nil {
|
||||
filters.DateRange.Start = startTime
|
||||
}
|
||||
}
|
||||
if endStr, ok := dateRange["end"].(string); ok && endStr != "" {
|
||||
if endTime, err := time.Parse("2006-01-02", endStr); err == nil {
|
||||
filters.DateRange.End = endTime
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse boolean filters
|
||||
if isFavorite, ok := searchReq["is_favorite"].(bool); ok {
|
||||
filters.IsFavorite = &isFavorite
|
||||
}
|
||||
if isRead, ok := searchReq["is_read"].(bool); ok {
|
||||
filters.IsRead = &isRead
|
||||
}
|
||||
if isPublic, ok := searchReq["is_public"].(bool); ok {
|
||||
filters.IsPublic = &isPublic
|
||||
}
|
||||
|
||||
// Perform the search using existing enhanced search logic
|
||||
results, err := performEnhancedSearch(filters, userID, db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Convert results to interface slice
|
||||
var interfaceResults []interface{}
|
||||
for _, result := range results {
|
||||
interfaceResults = append(interfaceResults, result)
|
||||
}
|
||||
|
||||
return interfaceResults, nil
|
||||
}
|
||||
|
||||
// Helper function to perform enhanced search (reused from search_enhanced.go)
|
||||
func performEnhancedSearch(filters SearchFilters, userID uint, db *gorm.DB) ([]SearchResult, error) {
|
||||
var results []SearchResult
|
||||
|
||||
// Search bookmarks
|
||||
if filters.ContentType == "all" || filters.ContentType == "bookmarks" {
|
||||
var bookmarks []models.Bookmark
|
||||
query := db.Where("user_id = ?", userID)
|
||||
|
||||
// Apply text search
|
||||
if filters.Query != "" {
|
||||
query = query.Where("title ILIKE ? OR description ILIKE ? OR content ILIKE ?",
|
||||
"%"+filters.Query+"%", "%"+filters.Query+"%", "%"+filters.Query+"%")
|
||||
}
|
||||
|
||||
// Apply filters
|
||||
if filters.IsFavorite != nil {
|
||||
query = query.Where("is_favorite = ?", *filters.IsFavorite)
|
||||
}
|
||||
|
||||
if err := query.Limit(filters.Limit).Offset(filters.Offset).Find(&bookmarks).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, bookmark := range bookmarks {
|
||||
result := SearchResult{
|
||||
ID: bookmark.ID,
|
||||
Type: "bookmark",
|
||||
Title: bookmark.Title,
|
||||
Description: bookmark.Description,
|
||||
Content: bookmark.Content,
|
||||
CreatedAt: bookmark.CreatedAt,
|
||||
UpdatedAt: bookmark.UpdatedAt,
|
||||
URL: bookmark.URL,
|
||||
IsFavorite: bookmark.IsFavorite,
|
||||
IsRead: bookmark.IsRead,
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
}
|
||||
|
||||
// Search tasks
|
||||
if filters.ContentType == "all" || filters.ContentType == "tasks" {
|
||||
var tasks []models.Task
|
||||
query := db.Where("user_id = ?", userID)
|
||||
|
||||
if filters.Query != "" {
|
||||
query = query.Where("title ILIKE ? OR description ILIKE ?",
|
||||
"%"+filters.Query+"%", "%"+filters.Query+"%")
|
||||
}
|
||||
|
||||
if err := query.Limit(filters.Limit).Offset(filters.Offset).Find(&tasks).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, task := range tasks {
|
||||
result := SearchResult{
|
||||
ID: task.ID,
|
||||
Type: "task",
|
||||
Title: task.Title,
|
||||
Description: task.Description,
|
||||
CreatedAt: task.CreatedAt,
|
||||
UpdatedAt: task.UpdatedAt,
|
||||
Status: string(task.Status),
|
||||
Priority: string(task.Priority),
|
||||
DueDate: task.DueDate,
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
}
|
||||
|
||||
// Search notes
|
||||
if filters.ContentType == "all" || filters.ContentType == "notes" {
|
||||
var notes []models.Note
|
||||
query := db.Where("user_id = ?", userID)
|
||||
|
||||
if filters.Query != "" {
|
||||
query = query.Where("title ILIKE ? OR content ILIKE ?",
|
||||
"%"+filters.Query+"%", "%"+filters.Query+"%")
|
||||
}
|
||||
|
||||
if err := query.Limit(filters.Limit).Offset(filters.Offset).Find(¬es).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, note := range notes {
|
||||
result := SearchResult{
|
||||
ID: note.ID,
|
||||
Type: "note",
|
||||
Title: note.Title,
|
||||
Description: note.Content[:min(200, len(note.Content))],
|
||||
Content: note.Content,
|
||||
CreatedAt: note.CreatedAt,
|
||||
UpdatedAt: note.UpdatedAt,
|
||||
IsPublic: note.IsPublic,
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
func getStringValue(m map[string]interface{}, key string) string {
|
||||
if val, ok := m[key].(string); ok {
|
||||
return val
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func getIntValue(m map[string]interface{}, key string, defaultValue int) int {
|
||||
if val, ok := m[key].(float64); ok {
|
||||
return int(val)
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// Helper function to log search analytics
|
||||
func logSearchAnalytics(userID uint, query string, filters string, resultsCount int, db *gorm.DB) {
|
||||
analytics := models.SearchAnalytics{
|
||||
UserID: userID,
|
||||
Query: query,
|
||||
Filters: filters,
|
||||
ResultsCount: resultsCount,
|
||||
Took: 0, // Would be measured in actual implementation
|
||||
ContentType: "mixed",
|
||||
}
|
||||
|
||||
db.Create(&analytics)
|
||||
}
|
||||
@@ -1,475 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// BraveSearchResponse represents the response from Brave Search API
|
||||
type BraveSearchResponse struct {
|
||||
Mixed struct {
|
||||
Results []map[string]interface{} `json:"results"`
|
||||
} `json:"mixed"`
|
||||
Web struct {
|
||||
Results []map[string]interface{} `json:"results"`
|
||||
} `json:"web"`
|
||||
Query struct {
|
||||
Original string `json:"original"`
|
||||
Display string `json:"display"`
|
||||
} `json:"query"`
|
||||
}
|
||||
|
||||
type BraveNewsResponse struct {
|
||||
Results []map[string]interface{} `json:"results"`
|
||||
Query struct {
|
||||
Original string `json:"original"`
|
||||
Display string `json:"display"`
|
||||
} `json:"query"`
|
||||
}
|
||||
|
||||
// BraveSearchResult represents a normalized search result returned to the frontend
|
||||
// Note: Brave's API uses fields like "page_age"; we normalize this to "published_date"
|
||||
// to match the BrowserSearch UI expectations.
|
||||
type BraveSearchResult struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Description string `json:"description"`
|
||||
PublishedDate string `json:"published_date,omitempty"`
|
||||
Language string `json:"language,omitempty"`
|
||||
}
|
||||
|
||||
// SearchWeb handles POST /api/v1/search/web
|
||||
func SearchWeb(c *gin.Context) {
|
||||
var req struct {
|
||||
Query string `json:"query" binding:"required"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Set default count if not provided
|
||||
if req.Count == 0 {
|
||||
req.Count = 10
|
||||
}
|
||||
|
||||
// Get user ID from context (authentication is required)
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required for search functionality"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get user's search settings from database
|
||||
searchSettings, err := GetSearchSettingsForAPI(userID.(int))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get search settings"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user has search API key configured
|
||||
if searchSettings.SearchAPIProvider == "brave" {
|
||||
apiKey := searchSettings.BraveAPIKey
|
||||
if apiKey == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Brave Search API key not configured. Please configure your search API key in settings."})
|
||||
return
|
||||
}
|
||||
|
||||
// Build Brave Search API request
|
||||
baseURL := "https://api.search.brave.com/res/v1/web/search"
|
||||
q := url.Values{}
|
||||
q.Set("q", req.Query)
|
||||
q.Set("count", fmt.Sprint(req.Count))
|
||||
endpoint := fmt.Sprintf("%s?%s", baseURL, q.Encode())
|
||||
|
||||
reqHTTP, err := http.NewRequest(http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create Brave request"})
|
||||
return
|
||||
}
|
||||
reqHTTP.Header.Set("Accept", "application/json")
|
||||
reqHTTP.Header.Set("X-Subscription-Token", apiKey)
|
||||
|
||||
resp, err := http.DefaultClient.Do(reqHTTP)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to contact Brave Search API"})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("Brave API error: %d", resp.StatusCode)})
|
||||
return
|
||||
}
|
||||
|
||||
var braveResp BraveSearchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&braveResp); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to decode Brave response"})
|
||||
return
|
||||
}
|
||||
|
||||
// Prefer web.results, fall back to mixed.results
|
||||
resultsRaw := braveResp.Web.Results
|
||||
if len(resultsRaw) == 0 {
|
||||
resultsRaw = braveResp.Mixed.Results
|
||||
}
|
||||
|
||||
results := make([]BraveSearchResult, 0, len(resultsRaw))
|
||||
for _, r := range resultsRaw {
|
||||
title, _ := r["title"].(string)
|
||||
urlStr, _ := r["url"].(string)
|
||||
desc, _ := r["description"].(string)
|
||||
lang, _ := r["language"].(string)
|
||||
pageAge, _ := r["page_age"].(string)
|
||||
|
||||
results = append(results, BraveSearchResult{
|
||||
Title: title,
|
||||
URL: urlStr,
|
||||
Description: desc,
|
||||
PublishedDate: pageAge,
|
||||
Language: lang,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"results": results,
|
||||
"query": gin.H{
|
||||
"original": braveResp.Query.Original,
|
||||
"display": braveResp.Query.Display,
|
||||
},
|
||||
"count": len(results),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Use the configured provider
|
||||
if searchSettings.SearchAPIProvider == "brave" {
|
||||
apiKey := searchSettings.BraveAPIKey
|
||||
if apiKey == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Brave Search API key not configured. Please configure your search API key in settings."})
|
||||
return
|
||||
}
|
||||
|
||||
// Build Brave Search API request
|
||||
baseURL := searchSettings.BraveSearchBaseURL
|
||||
q := url.Values{}
|
||||
q.Set("q", req.Query)
|
||||
q.Set("count", fmt.Sprint(req.Count))
|
||||
endpoint := fmt.Sprintf("%s?%s", baseURL, q.Encode())
|
||||
|
||||
reqHTTP, err := http.NewRequest(http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create Brave request"})
|
||||
return
|
||||
}
|
||||
reqHTTP.Header.Set("Accept", "application/json")
|
||||
reqHTTP.Header.Set("X-Subscription-Token", apiKey)
|
||||
|
||||
resp, err := http.DefaultClient.Do(reqHTTP)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to contact Brave Search API"})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("Brave API error: %d", resp.StatusCode)})
|
||||
return
|
||||
}
|
||||
|
||||
var braveResp BraveSearchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&braveResp); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to decode Brave response"})
|
||||
return
|
||||
}
|
||||
|
||||
// Prefer web.results, fall back to mixed.results
|
||||
resultsRaw := braveResp.Web.Results
|
||||
if len(resultsRaw) == 0 {
|
||||
resultsRaw = braveResp.Mixed.Results
|
||||
}
|
||||
|
||||
results := make([]BraveSearchResult, 0, len(resultsRaw))
|
||||
for _, r := range resultsRaw {
|
||||
title, _ := r["title"].(string)
|
||||
urlStr, _ := r["url"].(string)
|
||||
desc, _ := r["description"].(string)
|
||||
lang, _ := r["language"].(string)
|
||||
pageAge, _ := r["page_age"].(string)
|
||||
|
||||
results = append(results, BraveSearchResult{
|
||||
Title: title,
|
||||
URL: urlStr,
|
||||
Description: desc,
|
||||
PublishedDate: pageAge,
|
||||
Language: lang,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"results": results,
|
||||
"query": gin.H{
|
||||
"original": braveResp.Query.Original,
|
||||
"display": braveResp.Query.Display,
|
||||
},
|
||||
"count": len(results),
|
||||
})
|
||||
} else if searchSettings.SearchAPIProvider == "serper" {
|
||||
// TODO: Implement Serper API integration
|
||||
c.JSON(http.StatusNotImplemented, gin.H{"error": "Serper API integration not yet implemented"})
|
||||
} else {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "No valid search API provider configured. Please configure a search API provider in settings."})
|
||||
}
|
||||
}
|
||||
|
||||
// SearchNews handles POST /api/v1/search/news
|
||||
func SearchNews(c *gin.Context) {
|
||||
var req struct {
|
||||
Query string `json:"query" binding:"required"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Count == 0 {
|
||||
req.Count = 10
|
||||
}
|
||||
|
||||
// Get user ID from context (authentication is required)
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required for search functionality"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get user's search settings from database
|
||||
searchSettings, err := GetSearchSettingsForAPI(userID.(int))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get search settings"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user has search API key configured
|
||||
if searchSettings.SearchAPIProvider == "brave" {
|
||||
apiKey := searchSettings.BraveAPIKey
|
||||
if apiKey == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Brave Search API key not configured. Please configure your search API key in settings."})
|
||||
return
|
||||
}
|
||||
|
||||
baseURL := "https://api.search.brave.com/res/v1/news/search"
|
||||
q := url.Values{}
|
||||
q.Set("q", req.Query)
|
||||
q.Set("count", fmt.Sprint(req.Count))
|
||||
endpoint := fmt.Sprintf("%s?%s", baseURL, q.Encode())
|
||||
|
||||
reqHTTP, err := http.NewRequest(http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create Brave request"})
|
||||
return
|
||||
}
|
||||
reqHTTP.Header.Set("Accept", "application/json")
|
||||
reqHTTP.Header.Set("X-Subscription-Token", apiKey)
|
||||
|
||||
resp, err := http.DefaultClient.Do(reqHTTP)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to contact Brave News API"})
|
||||
return
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
resp.Body.Close()
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("Brave News API error: %d", resp.StatusCode)})
|
||||
return
|
||||
}
|
||||
|
||||
// Read the response body for debugging
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read response body"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
|
||||
var braveResp BraveNewsResponse
|
||||
if err := json.NewDecoder(bytes.NewReader(bodyBytes)).Decode(&braveResp); err != nil {
|
||||
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to decode Brave news response"})
|
||||
return
|
||||
}
|
||||
|
||||
// Debug logging
|
||||
|
||||
|
||||
|
||||
resultsRaw := braveResp.Results
|
||||
results := make([]BraveSearchResult, 0, len(resultsRaw))
|
||||
for _, r := range resultsRaw {
|
||||
title, _ := r["title"].(string)
|
||||
urlStr, _ := r["url"].(string)
|
||||
desc, _ := r["description"].(string)
|
||||
lang, _ := r["language"].(string)
|
||||
pubDate, _ := r["published_date"].(string)
|
||||
if pubDate == "" {
|
||||
pubDate, _ = r["page_age"].(string)
|
||||
}
|
||||
|
||||
results = append(results, BraveSearchResult{
|
||||
Title: title,
|
||||
URL: urlStr,
|
||||
Description: desc,
|
||||
PublishedDate: pubDate,
|
||||
Language: lang,
|
||||
})
|
||||
}
|
||||
|
||||
original := braveResp.Query.Original
|
||||
display := braveResp.Query.Display
|
||||
if original == "" {
|
||||
original = req.Query
|
||||
}
|
||||
if display == "" {
|
||||
display = req.Query
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"results": results,
|
||||
"query": gin.H{
|
||||
"original": original,
|
||||
"display": display,
|
||||
},
|
||||
"count": len(results),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Use the configured provider
|
||||
if searchSettings.SearchAPIProvider == "brave" {
|
||||
apiKey := searchSettings.BraveAPIKey
|
||||
if apiKey == "" {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Brave API key not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
baseURL := "https://api.search.brave.com/res/v1/news/search"
|
||||
q := url.Values{}
|
||||
q.Set("q", req.Query)
|
||||
q.Set("count", fmt.Sprint(req.Count))
|
||||
endpoint := fmt.Sprintf("%s?%s", baseURL, q.Encode())
|
||||
|
||||
reqHTTP, err := http.NewRequest(http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create Brave request"})
|
||||
return
|
||||
}
|
||||
reqHTTP.Header.Set("Accept", "application/json")
|
||||
reqHTTP.Header.Set("X-Subscription-Token", apiKey)
|
||||
|
||||
resp, err := http.DefaultClient.Do(reqHTTP)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to contact Brave News API"})
|
||||
return
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
resp.Body.Close()
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("Brave News API error: %d", resp.StatusCode)})
|
||||
return
|
||||
}
|
||||
|
||||
// Read the response body for debugging
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read response body"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
|
||||
var braveResp BraveNewsResponse
|
||||
if err := json.NewDecoder(bytes.NewReader(bodyBytes)).Decode(&braveResp); err != nil {
|
||||
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to decode Brave news response"})
|
||||
return
|
||||
}
|
||||
|
||||
// Debug logging
|
||||
|
||||
|
||||
|
||||
resultsRaw := braveResp.Results
|
||||
results := make([]BraveSearchResult, 0, len(resultsRaw))
|
||||
for _, r := range resultsRaw {
|
||||
title, _ := r["title"].(string)
|
||||
urlStr, _ := r["url"].(string)
|
||||
desc, _ := r["description"].(string)
|
||||
lang, _ := r["language"].(string)
|
||||
pubDate, _ := r["published_date"].(string)
|
||||
if pubDate == "" {
|
||||
pubDate, _ = r["page_age"].(string)
|
||||
}
|
||||
|
||||
results = append(results, BraveSearchResult{
|
||||
Title: title,
|
||||
URL: urlStr,
|
||||
Description: desc,
|
||||
PublishedDate: pubDate,
|
||||
Language: lang,
|
||||
})
|
||||
}
|
||||
|
||||
original := braveResp.Query.Original
|
||||
display := braveResp.Query.Display
|
||||
if original == "" {
|
||||
original = req.Query
|
||||
}
|
||||
if display == "" {
|
||||
display = req.Query
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"results": results,
|
||||
"query": gin.H{
|
||||
"original": original,
|
||||
"display": display,
|
||||
},
|
||||
"count": len(results),
|
||||
})
|
||||
} else if searchSettings.SearchAPIProvider == "serper" {
|
||||
// TODO: Implement Serper API integration for news
|
||||
c.JSON(http.StatusNotImplemented, gin.H{"error": "Serper API integration not yet implemented"})
|
||||
} else {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "No valid search API provider configured. Please configure a search API provider in settings."})
|
||||
}
|
||||
}
|
||||
|
||||
// GetSearchSuggestions handles GET /api/v1/search/suggestions
|
||||
func GetSearchSuggestions(c *gin.Context) {
|
||||
query := c.Query("q")
|
||||
if query == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Query parameter 'q' is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// For now, return empty suggestions
|
||||
// In a real implementation, you might want to implement autocomplete
|
||||
// using Brave's autocomplete API or your own suggestion engine
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"suggestions": []string{},
|
||||
"query": query,
|
||||
})
|
||||
}
|
||||
@@ -1,523 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/trackeep/backend/config"
|
||||
"github.com/trackeep/backend/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// SearchFilters represents the search filters
|
||||
type SearchFilters struct {
|
||||
Query string `json:"query" binding:"required"`
|
||||
ContentType string `json:"content_type"` // 'all' | 'bookmarks' | 'tasks' | 'notes' | 'files'
|
||||
Tags []string `json:"tags"`
|
||||
DateRange DateRange `json:"date_range"`
|
||||
Author string `json:"author"`
|
||||
Language string `json:"language"`
|
||||
FileTypes []string `json:"file_types"`
|
||||
IsFavorite *bool `json:"is_favorite"`
|
||||
IsRead *bool `json:"is_read"`
|
||||
IsPublic *bool `json:"is_public"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
}
|
||||
|
||||
type DateRange struct {
|
||||
Start time.Time `json:"start"`
|
||||
End time.Time `json:"end"`
|
||||
}
|
||||
|
||||
// SearchResult represents a unified search result
|
||||
type SearchResult struct {
|
||||
ID uint `json:"id"`
|
||||
Type string `json:"type"` // 'bookmark', 'task', 'note', 'file'
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Content string `json:"content"`
|
||||
Tags []models.Tag `json:"tags"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Priority string `json:"priority,omitempty"`
|
||||
DueDate *time.Time `json:"due_date,omitempty"`
|
||||
IsFavorite bool `json:"is_favorite,omitempty"`
|
||||
IsRead bool `json:"is_read,omitempty"`
|
||||
IsPublic bool `json:"is_public,omitempty"`
|
||||
Author string `json:"author,omitempty"`
|
||||
FileSize int64 `json:"file_size,omitempty"`
|
||||
MimeType string `json:"mime_type,omitempty"`
|
||||
FileType string `json:"file_type,omitempty"`
|
||||
Progress int `json:"progress,omitempty"`
|
||||
Highights map[string][]string `json:"highlights,omitempty"` // Search highlights
|
||||
Score float64 `json:"score"` // Relevance score
|
||||
}
|
||||
|
||||
// SearchResponse represents the search response
|
||||
type SearchResponse struct {
|
||||
Results []SearchResult `json:"results"`
|
||||
Total int64 `json:"total"`
|
||||
Query string `json:"query"`
|
||||
Filters SearchFilters `json:"filters"`
|
||||
Took int64 `json:"took"` // Time taken in milliseconds
|
||||
Suggestions []string `json:"suggestions"` // Search suggestions
|
||||
Aggregations map[string]int `json:"aggregations"` // Content type counts
|
||||
}
|
||||
|
||||
// EnhancedSearch handles POST /api/v1/search/enhanced
|
||||
func EnhancedSearch(c *gin.Context) {
|
||||
var filters SearchFilters
|
||||
if err := c.ShouldBindJSON(&filters); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Set defaults
|
||||
if filters.ContentType == "" {
|
||||
filters.ContentType = "all"
|
||||
}
|
||||
if filters.Limit == 0 {
|
||||
filters.Limit = 20
|
||||
}
|
||||
if filters.Limit > 100 {
|
||||
filters.Limit = 100
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
db := config.GetDB()
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
var results []SearchResult
|
||||
var total int64
|
||||
aggregations := make(map[string]int)
|
||||
|
||||
// Search based on content type
|
||||
switch filters.ContentType {
|
||||
case "bookmarks":
|
||||
results, total = searchBookmarks(db, userID, filters)
|
||||
aggregations["bookmarks"] = int(total)
|
||||
case "tasks":
|
||||
results, total = searchTasks(db, userID, filters)
|
||||
aggregations["tasks"] = int(total)
|
||||
case "notes":
|
||||
results, total = searchNotes(db, userID, filters)
|
||||
aggregations["notes"] = int(total)
|
||||
case "files":
|
||||
results, total = searchFiles(db, userID, filters)
|
||||
aggregations["files"] = int(total)
|
||||
default: // all
|
||||
bookmarkResults, bookmarkTotal := searchBookmarks(db, userID, filters)
|
||||
taskResults, taskTotal := searchTasks(db, userID, filters)
|
||||
noteResults, noteTotal := searchNotes(db, userID, filters)
|
||||
fileResults, fileTotal := searchFiles(db, userID, filters)
|
||||
|
||||
results = append(append(append(bookmarkResults, taskResults...), noteResults...), fileResults...)
|
||||
total = bookmarkTotal + taskTotal + noteTotal + fileTotal
|
||||
|
||||
aggregations["bookmarks"] = int(bookmarkTotal)
|
||||
aggregations["tasks"] = int(taskTotal)
|
||||
aggregations["notes"] = int(noteTotal)
|
||||
aggregations["files"] = int(fileTotal)
|
||||
}
|
||||
|
||||
// Apply pagination
|
||||
if filters.Offset > 0 && len(results) > filters.Offset {
|
||||
results = results[filters.Offset:]
|
||||
}
|
||||
if len(results) > filters.Limit {
|
||||
results = results[:filters.Limit]
|
||||
}
|
||||
|
||||
// Get search suggestions
|
||||
suggestions := getSearchSuggestions(db, userID, filters.Query)
|
||||
|
||||
// Calculate time taken
|
||||
took := time.Since(startTime).Milliseconds()
|
||||
|
||||
response := SearchResponse{
|
||||
Results: results,
|
||||
Total: total,
|
||||
Query: filters.Query,
|
||||
Filters: filters,
|
||||
Took: took,
|
||||
Suggestions: suggestions,
|
||||
Aggregations: aggregations,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// searchBookmarks searches bookmarks with filters
|
||||
func searchBookmarks(db *gorm.DB, userID uint, filters SearchFilters) ([]SearchResult, int64) {
|
||||
var bookmarks []models.Bookmark
|
||||
var results []SearchResult
|
||||
|
||||
query := db.Where("user_id = ?", userID)
|
||||
|
||||
// Text search
|
||||
if filters.Query != "" {
|
||||
searchTerm := "%" + strings.ToLower(filters.Query) + "%"
|
||||
query = query.Where("LOWER(title) LIKE ? OR LOWER(description) LIKE ? OR LOWER(content) LIKE ? OR LOWER(url) LIKE ?",
|
||||
searchTerm, searchTerm, searchTerm, searchTerm)
|
||||
}
|
||||
|
||||
// Tags filter
|
||||
if len(filters.Tags) > 0 {
|
||||
query = query.Joins("JOIN bookmark_tags ON bookmarks.id = bookmark_tags.bookmark_id").
|
||||
Joins("JOIN tags ON bookmark_tags.tag_id = tags.id").
|
||||
Where("tags.name IN ?", filters.Tags)
|
||||
}
|
||||
|
||||
// Date range filter
|
||||
if !filters.DateRange.Start.IsZero() {
|
||||
query = query.Where("created_at >= ?", filters.DateRange.Start)
|
||||
}
|
||||
if !filters.DateRange.End.IsZero() {
|
||||
query = query.Where("created_at <= ?", filters.DateRange.End)
|
||||
}
|
||||
|
||||
// Boolean filters
|
||||
if filters.IsFavorite != nil {
|
||||
query = query.Where("is_favorite = ?", *filters.IsFavorite)
|
||||
}
|
||||
if filters.IsRead != nil {
|
||||
query = query.Where("is_read = ?", *filters.IsRead)
|
||||
}
|
||||
|
||||
// Author filter
|
||||
if filters.Author != "" {
|
||||
query = query.Where("LOWER(author) LIKE ?", "%"+strings.ToLower(filters.Author)+"%")
|
||||
}
|
||||
|
||||
// Count total
|
||||
var total int64
|
||||
query.Model(&models.Bookmark{}).Count(&total)
|
||||
|
||||
// Get results with tags
|
||||
if err := query.Preload("Tags").Find(&bookmarks).Error; err != nil {
|
||||
return results, 0
|
||||
}
|
||||
|
||||
// Convert to search results
|
||||
for _, bookmark := range bookmarks {
|
||||
result := SearchResult{
|
||||
ID: bookmark.ID,
|
||||
Type: "bookmark",
|
||||
Title: bookmark.Title,
|
||||
Description: bookmark.Description,
|
||||
Content: bookmark.Content,
|
||||
Tags: bookmark.Tags,
|
||||
CreatedAt: bookmark.CreatedAt,
|
||||
UpdatedAt: bookmark.UpdatedAt,
|
||||
URL: bookmark.URL,
|
||||
IsFavorite: bookmark.IsFavorite,
|
||||
IsRead: bookmark.IsRead,
|
||||
Author: bookmark.Author,
|
||||
Score: calculateRelevanceScore(filters.Query, bookmark.Title, bookmark.Description, bookmark.Content),
|
||||
}
|
||||
|
||||
if bookmark.PublishedAt != nil {
|
||||
result.DueDate = bookmark.PublishedAt // Using DueDate field for published date
|
||||
}
|
||||
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
return results, total
|
||||
}
|
||||
|
||||
// searchTasks searches tasks with filters
|
||||
func searchTasks(db *gorm.DB, userID uint, filters SearchFilters) ([]SearchResult, int64) {
|
||||
var tasks []models.Task
|
||||
var results []SearchResult
|
||||
|
||||
query := db.Where("user_id = ?", userID)
|
||||
|
||||
// Text search
|
||||
if filters.Query != "" {
|
||||
searchTerm := "%" + strings.ToLower(filters.Query) + "%"
|
||||
query = query.Where("LOWER(title) LIKE ? OR LOWER(description) LIKE ?", searchTerm, searchTerm)
|
||||
}
|
||||
|
||||
// Tags filter
|
||||
if len(filters.Tags) > 0 {
|
||||
query = query.Joins("JOIN task_tags ON tasks.id = task_tags.task_id").
|
||||
Joins("JOIN tags ON task_tags.tag_id = tags.id").
|
||||
Where("tags.name IN ?", filters.Tags)
|
||||
}
|
||||
|
||||
// Date range filter
|
||||
if !filters.DateRange.Start.IsZero() {
|
||||
query = query.Where("created_at >= ?", filters.DateRange.Start)
|
||||
}
|
||||
if !filters.DateRange.End.IsZero() {
|
||||
query = query.Where("created_at <= ?", filters.DateRange.End)
|
||||
}
|
||||
|
||||
// Count total
|
||||
var total int64
|
||||
query.Model(&models.Task{}).Count(&total)
|
||||
|
||||
// Get results with tags
|
||||
if err := query.Preload("Tags").Find(&tasks).Error; err != nil {
|
||||
return results, 0
|
||||
}
|
||||
|
||||
// Convert to search results
|
||||
for _, task := range tasks {
|
||||
result := SearchResult{
|
||||
ID: task.ID,
|
||||
Type: "task",
|
||||
Title: task.Title,
|
||||
Description: task.Description,
|
||||
Tags: task.Tags,
|
||||
CreatedAt: task.CreatedAt,
|
||||
UpdatedAt: task.UpdatedAt,
|
||||
Status: string(task.Status),
|
||||
Priority: string(task.Priority),
|
||||
DueDate: task.DueDate,
|
||||
Progress: task.Progress,
|
||||
Score: calculateRelevanceScore(filters.Query, task.Title, task.Description, ""),
|
||||
}
|
||||
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
return results, total
|
||||
}
|
||||
|
||||
// searchNotes searches notes with filters
|
||||
func searchNotes(db *gorm.DB, userID uint, filters SearchFilters) ([]SearchResult, int64) {
|
||||
var notes []models.Note
|
||||
var results []SearchResult
|
||||
|
||||
query := db.Where("user_id = ?", userID)
|
||||
|
||||
// Text search
|
||||
if filters.Query != "" {
|
||||
searchTerm := "%" + strings.ToLower(filters.Query) + "%"
|
||||
query = query.Where("LOWER(title) LIKE ? OR LOWER(description) LIKE ? OR LOWER(content) LIKE ?",
|
||||
searchTerm, searchTerm, searchTerm)
|
||||
}
|
||||
|
||||
// Tags filter
|
||||
if len(filters.Tags) > 0 {
|
||||
query = query.Joins("JOIN note_tags ON notes.id = note_tags.note_id").
|
||||
Joins("JOIN tags ON note_tags.tag_id = tags.id").
|
||||
Where("tags.name IN ?", filters.Tags)
|
||||
}
|
||||
|
||||
// Date range filter
|
||||
if !filters.DateRange.Start.IsZero() {
|
||||
query = query.Where("created_at >= ?", filters.DateRange.Start)
|
||||
}
|
||||
if !filters.DateRange.End.IsZero() {
|
||||
query = query.Where("created_at <= ?", filters.DateRange.End)
|
||||
}
|
||||
|
||||
// Boolean filters
|
||||
if filters.IsPublic != nil {
|
||||
query = query.Where("is_public = ?", *filters.IsPublic)
|
||||
}
|
||||
|
||||
// Count total
|
||||
var total int64
|
||||
query.Model(&models.Note{}).Count(&total)
|
||||
|
||||
// Get results with tags
|
||||
if err := query.Preload("Tags").Find(¬es).Error; err != nil {
|
||||
return results, 0
|
||||
}
|
||||
|
||||
// Convert to search results
|
||||
for _, note := range notes {
|
||||
result := SearchResult{
|
||||
ID: note.ID,
|
||||
Type: "note",
|
||||
Title: note.Title,
|
||||
Description: note.Description,
|
||||
Content: note.Content,
|
||||
Tags: note.Tags,
|
||||
CreatedAt: note.CreatedAt,
|
||||
UpdatedAt: note.UpdatedAt,
|
||||
IsPublic: note.IsPublic,
|
||||
Score: calculateRelevanceScore(filters.Query, note.Title, note.Description, note.Content),
|
||||
}
|
||||
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
return results, total
|
||||
}
|
||||
|
||||
// searchFiles searches files with filters
|
||||
func searchFiles(db *gorm.DB, userID uint, filters SearchFilters) ([]SearchResult, int64) {
|
||||
var files []models.File
|
||||
var results []SearchResult
|
||||
|
||||
query := db.Where("user_id = ?", userID)
|
||||
|
||||
// Text search
|
||||
if filters.Query != "" {
|
||||
searchTerm := "%" + strings.ToLower(filters.Query) + "%"
|
||||
query = query.Where("LOWER(original_name) LIKE ? OR LOWER(description) LIKE ? OR LOWER(content) LIKE ?",
|
||||
searchTerm, searchTerm, searchTerm)
|
||||
}
|
||||
|
||||
// Tags filter
|
||||
if len(filters.Tags) > 0 {
|
||||
query = query.Joins("JOIN file_tags ON files.id = file_tags.file_id").
|
||||
Joins("JOIN tags ON file_tags.tag_id = tags.id").
|
||||
Where("tags.name IN ?", filters.Tags)
|
||||
}
|
||||
|
||||
// Date range filter
|
||||
if !filters.DateRange.Start.IsZero() {
|
||||
query = query.Where("created_at >= ?", filters.DateRange.Start)
|
||||
}
|
||||
if !filters.DateRange.End.IsZero() {
|
||||
query = query.Where("created_at <= ?", filters.DateRange.End)
|
||||
}
|
||||
|
||||
// File type filter
|
||||
if len(filters.FileTypes) > 0 {
|
||||
query = query.Where("file_type IN ?", filters.FileTypes)
|
||||
}
|
||||
|
||||
// Boolean filters
|
||||
if filters.IsPublic != nil {
|
||||
query = query.Where("is_public = ?", *filters.IsPublic)
|
||||
}
|
||||
|
||||
// Count total
|
||||
var total int64
|
||||
query.Model(&models.File{}).Count(&total)
|
||||
|
||||
// Get results with tags
|
||||
if err := query.Preload("Tags").Find(&files).Error; err != nil {
|
||||
return results, 0
|
||||
}
|
||||
|
||||
// Convert to search results
|
||||
for _, file := range files {
|
||||
result := SearchResult{
|
||||
ID: file.ID,
|
||||
Type: "file",
|
||||
Title: file.OriginalName,
|
||||
Description: file.Description,
|
||||
Content: file.Content,
|
||||
Tags: file.Tags,
|
||||
CreatedAt: file.CreatedAt,
|
||||
UpdatedAt: file.UpdatedAt,
|
||||
FileSize: file.FileSize,
|
||||
MimeType: file.MimeType,
|
||||
FileType: string(file.FileType),
|
||||
IsPublic: file.IsPublic,
|
||||
Score: calculateRelevanceScore(filters.Query, file.OriginalName, file.Description, file.Content),
|
||||
}
|
||||
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
return results, total
|
||||
}
|
||||
|
||||
// calculateRelevanceScore calculates a simple relevance score for search results
|
||||
func calculateRelevanceScore(query, title, description, content string) float64 {
|
||||
if query == "" {
|
||||
return 1.0
|
||||
}
|
||||
|
||||
queryLower := strings.ToLower(query)
|
||||
titleLower := strings.ToLower(title)
|
||||
descLower := strings.ToLower(description)
|
||||
contentLower := strings.ToLower(content)
|
||||
|
||||
score := 0.0
|
||||
|
||||
// Title matches are most important
|
||||
if strings.Contains(titleLower, queryLower) {
|
||||
score += 10.0
|
||||
if strings.HasPrefix(titleLower, queryLower) {
|
||||
score += 5.0 // Bonus for prefix match
|
||||
}
|
||||
}
|
||||
|
||||
// Description matches
|
||||
if strings.Contains(descLower, queryLower) {
|
||||
score += 5.0
|
||||
}
|
||||
|
||||
// Content matches
|
||||
if strings.Contains(contentLower, queryLower) {
|
||||
score += 2.0
|
||||
}
|
||||
|
||||
// Word-based scoring
|
||||
queryWords := strings.Fields(queryLower)
|
||||
for _, word := range queryWords {
|
||||
if strings.Contains(titleLower, word) {
|
||||
score += 3.0
|
||||
}
|
||||
if strings.Contains(descLower, word) {
|
||||
score += 1.5
|
||||
}
|
||||
if strings.Contains(contentLower, word) {
|
||||
score += 1.0
|
||||
}
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
// getSearchSuggestions gets search suggestions based on user's search history and popular content
|
||||
func getSearchSuggestions(db *gorm.DB, userID uint, query string) []string {
|
||||
// For now, return empty suggestions
|
||||
// In a future implementation, this could:
|
||||
// - Look at user's search history
|
||||
// - Suggest popular tags
|
||||
// - Suggest based on content titles
|
||||
// - Use AI to generate semantic suggestions
|
||||
return []string{}
|
||||
}
|
||||
|
||||
// SaveSearch handles POST /api/v1/search/save
|
||||
func SaveSearch(c *gin.Context) {
|
||||
var req struct {
|
||||
Query string `json:"query" binding:"required"`
|
||||
Filters SearchFilters `json:"filters"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Alert bool `json:"alert"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Implement saved searches functionality
|
||||
// This would require a SavedSearch model
|
||||
|
||||
c.JSON(http.StatusNotImplemented, gin.H{
|
||||
"message": "Saved searches functionality coming soon",
|
||||
})
|
||||
}
|
||||
|
||||
// GetSearchAnalytics handles GET /api/v1/search/analytics
|
||||
func GetSearchAnalytics(c *gin.Context) {
|
||||
// TODO: Implement search analytics
|
||||
// This could include:
|
||||
// - Most searched terms
|
||||
// - Search frequency over time
|
||||
// - Content type distribution
|
||||
// - Popular filters
|
||||
|
||||
c.JSON(http.StatusNotImplemented, gin.H{
|
||||
"message": "Search analytics functionality coming soon",
|
||||
})
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/trackeep/backend/models"
|
||||
)
|
||||
|
||||
// SearchSettings represents search API configuration
|
||||
type SearchSettings struct {
|
||||
BraveAPIKey string `json:"brave_api_key"`
|
||||
BraveSearchBaseURL string `json:"brave_search_base_url"`
|
||||
SerperAPIKey string `json:"serper_api_key"`
|
||||
SerperBaseURL string `json:"serper_base_url"`
|
||||
SearchAPIProvider string `json:"search_api_provider"`
|
||||
SearchResultsLimit int `json:"search_results_limit"`
|
||||
SearchCacheTTL int `json:"search_cache_ttl"`
|
||||
SearchRateLimit int `json:"search_rate_limit"`
|
||||
}
|
||||
|
||||
// GetSearchSettings handles GET /api/v1/auth/search/settings
|
||||
func GetSearchSettings(c *gin.Context) {
|
||||
userID := c.GetInt("user_id")
|
||||
|
||||
// Get settings from database
|
||||
settings, err := models.GetUserSearchSettings(uint(userID))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get settings"})
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to response format
|
||||
response := SearchSettings{
|
||||
BraveSearchBaseURL: settings.BraveSearchBaseURL,
|
||||
SerperBaseURL: settings.SerperBaseURL,
|
||||
SearchAPIProvider: settings.SearchAPIProvider,
|
||||
SearchResultsLimit: settings.SearchResultsLimit,
|
||||
SearchCacheTTL: settings.SearchCacheTTL,
|
||||
SearchRateLimit: settings.SearchRateLimit,
|
||||
}
|
||||
|
||||
// Mask API keys for security
|
||||
if settings.BraveAPIKey != "" && len(settings.BraveAPIKey) > 8 {
|
||||
response.BraveAPIKey = settings.BraveAPIKey[:4] + "********" + settings.BraveAPIKey[len(settings.BraveAPIKey)-4:]
|
||||
}
|
||||
if settings.SerperAPIKey != "" && len(settings.SerperAPIKey) > 8 {
|
||||
response.SerperAPIKey = settings.SerperAPIKey[:4] + "********" + settings.SerperAPIKey[len(settings.SerperAPIKey)-4:]
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// UpdateSearchSettings handles PUT /api/v1/auth/search/settings
|
||||
func UpdateSearchSettings(c *gin.Context) {
|
||||
userID := c.GetInt("user_id")
|
||||
|
||||
var newSettings SearchSettings
|
||||
if err := c.ShouldBindJSON(&newSettings); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Get existing settings to preserve API keys if they're masked
|
||||
existingSettings, err := models.GetUserSearchSettings(uint(userID))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get existing settings"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if API keys are masked and preserve existing values
|
||||
if len(newSettings.BraveAPIKey) > 8 && newSettings.BraveAPIKey[4:12] == "********" {
|
||||
newSettings.BraveAPIKey = existingSettings.BraveAPIKey
|
||||
}
|
||||
if len(newSettings.SerperAPIKey) > 8 && newSettings.SerperAPIKey[4:12] == "********" {
|
||||
newSettings.SerperAPIKey = existingSettings.SerperAPIKey
|
||||
}
|
||||
|
||||
// Update model
|
||||
updatedSettings := &models.UserSearchSettings{
|
||||
BraveAPIKey: newSettings.BraveAPIKey,
|
||||
BraveSearchBaseURL: newSettings.BraveSearchBaseURL,
|
||||
SerperAPIKey: newSettings.SerperAPIKey,
|
||||
SerperBaseURL: newSettings.SerperBaseURL,
|
||||
SearchAPIProvider: newSettings.SearchAPIProvider,
|
||||
SearchResultsLimit: newSettings.SearchResultsLimit,
|
||||
SearchCacheTTL: newSettings.SearchCacheTTL,
|
||||
SearchRateLimit: newSettings.SearchRateLimit,
|
||||
}
|
||||
|
||||
// Save to database
|
||||
err = models.SaveUserSearchSettings(uint(userID), updatedSettings)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save settings"})
|
||||
return
|
||||
}
|
||||
|
||||
// Return masked settings for consistency
|
||||
GetSearchSettings(c)
|
||||
}
|
||||
|
||||
// GetTestSearchSettings handles GET /api/v1/test-search-settings (for demo mode)
|
||||
func GetTestSearchSettings(c *gin.Context) {
|
||||
settings := getDefaultSearchSettings()
|
||||
|
||||
// Mask API keys for security
|
||||
if settings.BraveAPIKey != "" && len(settings.BraveAPIKey) > 8 {
|
||||
settings.BraveAPIKey = settings.BraveAPIKey[:4] + "********" + settings.BraveAPIKey[len(settings.BraveAPIKey)-4:]
|
||||
}
|
||||
if settings.SerperAPIKey != "" && len(settings.SerperAPIKey) > 8 {
|
||||
settings.SerperAPIKey = settings.SerperAPIKey[:4] + "********" + settings.SerperAPIKey[len(settings.SerperAPIKey)-4:]
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, settings)
|
||||
}
|
||||
|
||||
// GetSearchSettingsForAPI returns unmasked search settings for internal API use
|
||||
func GetSearchSettingsForAPI(userID int) (SearchSettings, error) {
|
||||
settings, err := models.GetUserSearchSettings(uint(userID))
|
||||
if err != nil {
|
||||
// Return default settings if error
|
||||
defaultSettings := getDefaultSearchSettings()
|
||||
return defaultSettings, nil
|
||||
}
|
||||
|
||||
return SearchSettings{
|
||||
BraveAPIKey: settings.BraveAPIKey,
|
||||
BraveSearchBaseURL: settings.BraveSearchBaseURL,
|
||||
SerperAPIKey: settings.SerperAPIKey,
|
||||
SerperBaseURL: settings.SerperBaseURL,
|
||||
SearchAPIProvider: settings.SearchAPIProvider,
|
||||
SearchResultsLimit: settings.SearchResultsLimit,
|
||||
SearchCacheTTL: settings.SearchCacheTTL,
|
||||
SearchRateLimit: settings.SearchRateLimit,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func getDefaultSearchSettings() SearchSettings {
|
||||
return SearchSettings{
|
||||
BraveAPIKey: getEnvWithDefault("BRAVE_API_KEY", "BSAw0HNI1v3rKmXlSTr0C_UfZDjw7fT"),
|
||||
BraveSearchBaseURL: getEnvWithDefault("BRAVE_SEARCH_BASE_URL", "https://api.search.brave.com/res/v1/web/search"),
|
||||
SerperAPIKey: getEnvWithDefault("SERPER_API_KEY", "6f1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2"),
|
||||
SerperBaseURL: getEnvWithDefault("SERPER_BASE_URL", "https://google.serper.dev/search"),
|
||||
SearchAPIProvider: getEnvWithDefault("SEARCH_API_PROVIDER", "brave"),
|
||||
SearchResultsLimit: getIntEnvWithDefault("SEARCH_RESULTS_LIMIT", 10),
|
||||
SearchCacheTTL: getIntEnvWithDefault("SEARCH_CACHE_TTL", 300),
|
||||
SearchRateLimit: getIntEnvWithDefault("SEARCH_RATE_LIMIT", 100),
|
||||
}
|
||||
}
|
||||
|
||||
func getEnvWithDefault(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func getIntEnvWithDefault(key string, defaultValue int) int {
|
||||
value := os.Getenv(key)
|
||||
if value == "" {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
if intValue, err := strconv.Atoi(value); err == nil {
|
||||
return intValue
|
||||
}
|
||||
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func getBoolEnvWithDefault(key string, defaultValue bool) bool {
|
||||
value := os.Getenv(key)
|
||||
if value == "" {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
if value == "true" || value == "1" {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -1,603 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/trackeep/backend/config"
|
||||
"github.com/trackeep/backend/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// SemanticSearchRequest represents a semantic search request
|
||||
type SemanticSearchRequest struct {
|
||||
Query string `json:"query" binding:"required"`
|
||||
ContentType string `json:"content_type"` // all | bookmarks | tasks | notes | files | calendar_events | youtube_videos | learning_paths | chat_messages
|
||||
Limit int `json:"limit"`
|
||||
Threshold float64 `json:"threshold"` // Similarity threshold (0-1)
|
||||
}
|
||||
|
||||
// SemanticSearchResponse represents semantic search response
|
||||
type SemanticSearchResponse struct {
|
||||
Results []SemanticSearchResult `json:"results"`
|
||||
Query string `json:"query"`
|
||||
Took int64 `json:"took"`
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
// SemanticSearchResult represents a semantic search result
|
||||
type SemanticSearchResult struct {
|
||||
ID uint `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Content string `json:"content"`
|
||||
Similarity float64 `json:"similarity"`
|
||||
Highlights []string `json:"highlights"`
|
||||
Tags []models.Tag `json:"tags,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Priority string `json:"priority,omitempty"`
|
||||
}
|
||||
|
||||
// GenerateEmbeddingRequest represents request to generate embeddings
|
||||
type GenerateEmbeddingRequest struct {
|
||||
Text string `json:"text" binding:"required"`
|
||||
ContentType string `json:"content_type"`
|
||||
ContentID uint `json:"content_id"`
|
||||
}
|
||||
|
||||
// GenerateEmbeddingResponse represents embedding generation response
|
||||
type GenerateEmbeddingResponse struct {
|
||||
Embedding []float64 `json:"embedding"`
|
||||
Model string `json:"model"`
|
||||
Dimensions int `json:"dimensions"`
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// SemanticSearch handles POST /api/v1/search/semantic
|
||||
func SemanticSearch(c *gin.Context) {
|
||||
var req SemanticSearchRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Set defaults
|
||||
if req.Limit == 0 {
|
||||
req.Limit = 20
|
||||
}
|
||||
if req.Threshold == 0 {
|
||||
req.Threshold = 0.7 // Default similarity threshold
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
db := config.GetDB()
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
// Generate embedding for the search query
|
||||
queryEmbedding, err := generateEmbedding(req.Query)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Failed to generate query embedding",
|
||||
"details": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Search for similar content
|
||||
results, err := findSimilarContent(db, userID, queryEmbedding, req.ContentType, req.Limit, req.Threshold)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Failed to search similar content",
|
||||
"details": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
took := time.Since(startTime).Milliseconds()
|
||||
|
||||
response := SemanticSearchResponse{
|
||||
Results: results,
|
||||
Query: req.Query,
|
||||
Took: took,
|
||||
Model: "text-embedding-ada-002",
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// GenerateEmbedding handles POST /api/v1/search/embeddings/generate
|
||||
func GenerateEmbedding(c *gin.Context) {
|
||||
var req GenerateEmbeddingRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate embedding
|
||||
embedding, err := generateEmbedding(req.Text)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Failed to generate embedding",
|
||||
"details": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Store embedding if content reference is provided
|
||||
if req.ContentType != "" && req.ContentID > 0 {
|
||||
db := config.GetDB()
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
embeddingJSON, _ := json.Marshal(embedding)
|
||||
|
||||
contentEmbedding := models.ContentEmbedding{
|
||||
ContentType: req.ContentType,
|
||||
ContentID: req.ContentID,
|
||||
Embedding: string(embeddingJSON),
|
||||
Model: "text-embedding-ada-002",
|
||||
Dimensions: len(embedding),
|
||||
TextContent: req.Text,
|
||||
UserID: userID,
|
||||
}
|
||||
|
||||
if err := db.Create(&contentEmbedding).Error; err != nil {
|
||||
// Log error but don't fail the request
|
||||
log.Printf("Failed to store embedding: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
response := GenerateEmbeddingResponse{
|
||||
Embedding: embedding,
|
||||
Model: "text-embedding-ada-002",
|
||||
Dimensions: len(embedding),
|
||||
Success: true,
|
||||
Message: "Embedding generated successfully",
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// ReindexContent handles POST /api/v1/search/reindex
|
||||
func ReindexContent(c *gin.Context) {
|
||||
db := config.GetDB()
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
// Start background job to reindex all content
|
||||
go func() {
|
||||
reindexUserContent(db, userID)
|
||||
}()
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Content reindexing started in background",
|
||||
"status": "processing",
|
||||
})
|
||||
}
|
||||
|
||||
// generateEmbedding generates embedding for text using OpenAI API (mock implementation)
|
||||
func generateEmbedding(text string) ([]float64, error) {
|
||||
// TODO: Replace with actual OpenAI API call
|
||||
// For now, return a mock embedding for demonstration
|
||||
embedding := make([]float64, 1536) // OpenAI embedding dimensions
|
||||
|
||||
// Generate pseudo-random but deterministic embedding based on text
|
||||
hash := simpleHash(text)
|
||||
for i := range embedding {
|
||||
embedding[i] = math.Sin(float64(hash+i)) * 0.5
|
||||
}
|
||||
|
||||
return embedding, nil
|
||||
}
|
||||
|
||||
// simpleHash creates a simple hash from string
|
||||
func simpleHash(s string) int {
|
||||
hash := 0
|
||||
for _, char := range s {
|
||||
hash = hash*31 + int(char)
|
||||
}
|
||||
return hash
|
||||
}
|
||||
|
||||
// findSimilarContent finds content similar to the given embedding
|
||||
func findSimilarContent(db *gorm.DB, userID uint, queryEmbedding []float64, contentType string, limit int, threshold float64) ([]SemanticSearchResult, error) {
|
||||
var results []SemanticSearchResult
|
||||
|
||||
// Get all embeddings for the user
|
||||
var embeddings []models.ContentEmbedding
|
||||
query := db.Where("user_id = ?", userID)
|
||||
|
||||
if contentType != "all" && contentType != "" {
|
||||
query = query.Where("content_type = ?", normalizeSemanticContentType(contentType))
|
||||
}
|
||||
|
||||
if err := query.Find(&embeddings).Error; err != nil {
|
||||
return results, err
|
||||
}
|
||||
|
||||
// Calculate similarity scores
|
||||
type similarityScore struct {
|
||||
embedding models.ContentEmbedding
|
||||
score float64
|
||||
}
|
||||
|
||||
var scores []similarityScore
|
||||
|
||||
for _, embedding := range embeddings {
|
||||
var storedEmbedding []float64
|
||||
if err := json.Unmarshal([]byte(embedding.Embedding), &storedEmbedding); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
similarity := cosineSimilarity(queryEmbedding, storedEmbedding)
|
||||
if similarity >= threshold {
|
||||
scores = append(scores, similarityScore{
|
||||
embedding: embedding,
|
||||
score: similarity,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by similarity (descending)
|
||||
for i := 0; i < len(scores)-1; i++ {
|
||||
for j := i + 1; j < len(scores); j++ {
|
||||
if scores[i].score < scores[j].score {
|
||||
scores[i], scores[j] = scores[j], scores[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Limit results
|
||||
if len(scores) > limit {
|
||||
scores = scores[:limit]
|
||||
}
|
||||
|
||||
// Fetch actual content and build results
|
||||
for _, score := range scores {
|
||||
result, err := buildSemanticSearchResult(db, score.embedding, score.score)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// cosineSimilarity calculates cosine similarity between two vectors
|
||||
func cosineSimilarity(a, b []float64) float64 {
|
||||
if len(a) != len(b) {
|
||||
return 0
|
||||
}
|
||||
|
||||
var dotProduct, normA, normB float64
|
||||
|
||||
for i := range a {
|
||||
dotProduct += a[i] * b[i]
|
||||
normA += a[i] * a[i]
|
||||
normB += b[i] * b[i]
|
||||
}
|
||||
|
||||
if normA == 0 || normB == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
return dotProduct / (math.Sqrt(normA) * math.Sqrt(normB))
|
||||
}
|
||||
|
||||
// buildSemanticSearchResult builds a search result from embedding and content
|
||||
func buildSemanticSearchResult(db *gorm.DB, embedding models.ContentEmbedding, similarity float64) (SemanticSearchResult, error) {
|
||||
result := SemanticSearchResult{
|
||||
Similarity: similarity,
|
||||
}
|
||||
|
||||
switch embedding.ContentType {
|
||||
case "bookmark":
|
||||
var bookmark models.Bookmark
|
||||
if err := db.Preload("Tags").First(&bookmark, embedding.ContentID).Error; err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
result.ID = bookmark.ID
|
||||
result.Type = "bookmark"
|
||||
result.Title = bookmark.Title
|
||||
result.Description = bookmark.Description
|
||||
result.Content = bookmark.Content
|
||||
result.Tags = bookmark.Tags
|
||||
result.CreatedAt = bookmark.CreatedAt
|
||||
result.UpdatedAt = bookmark.UpdatedAt
|
||||
result.URL = bookmark.URL
|
||||
|
||||
case "task":
|
||||
var task models.Task
|
||||
if err := db.Preload("Tags").First(&task, embedding.ContentID).Error; err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
result.ID = task.ID
|
||||
result.Type = "task"
|
||||
result.Title = task.Title
|
||||
result.Description = task.Description
|
||||
result.Tags = task.Tags
|
||||
result.CreatedAt = task.CreatedAt
|
||||
result.UpdatedAt = task.UpdatedAt
|
||||
result.Status = string(task.Status)
|
||||
result.Priority = string(task.Priority)
|
||||
|
||||
case "note":
|
||||
var note models.Note
|
||||
if err := db.Preload("Tags").First(¬e, embedding.ContentID).Error; err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
result.ID = note.ID
|
||||
result.Type = "note"
|
||||
result.Title = note.Title
|
||||
result.Description = note.Description
|
||||
result.Content = note.Content
|
||||
result.Tags = note.Tags
|
||||
result.CreatedAt = note.CreatedAt
|
||||
result.UpdatedAt = note.UpdatedAt
|
||||
|
||||
case "file":
|
||||
var file models.File
|
||||
if err := db.Preload("Tags").First(&file, embedding.ContentID).Error; err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
result.ID = file.ID
|
||||
result.Type = "file"
|
||||
result.Title = file.OriginalName
|
||||
result.Description = file.Description
|
||||
result.Content = file.Content
|
||||
result.Tags = file.Tags
|
||||
result.CreatedAt = file.CreatedAt
|
||||
result.UpdatedAt = file.UpdatedAt
|
||||
|
||||
case "calendar_event":
|
||||
var event models.CalendarEvent
|
||||
if err := db.First(&event, embedding.ContentID).Error; err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
result.ID = event.ID
|
||||
result.Type = "calendar_event"
|
||||
result.Title = event.Title
|
||||
result.Description = event.Description
|
||||
result.Content = event.Description
|
||||
result.CreatedAt = event.CreatedAt
|
||||
result.UpdatedAt = event.UpdatedAt
|
||||
result.Priority = event.Priority
|
||||
|
||||
case "youtube_video":
|
||||
var video models.VideoBookmark
|
||||
if err := db.First(&video, embedding.ContentID).Error; err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
result.ID = video.ID
|
||||
result.Type = "youtube_video"
|
||||
result.Title = video.Title
|
||||
result.Description = video.Description
|
||||
result.Content = video.Description
|
||||
result.CreatedAt = video.CreatedAt
|
||||
result.UpdatedAt = video.UpdatedAt
|
||||
result.URL = video.URL
|
||||
|
||||
case "learning_path":
|
||||
var path models.LearningPath
|
||||
if err := db.First(&path, embedding.ContentID).Error; err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
result.ID = path.ID
|
||||
result.Type = "learning_path"
|
||||
result.Title = path.Title
|
||||
result.Description = path.Description
|
||||
result.Content = path.Description
|
||||
result.CreatedAt = path.CreatedAt
|
||||
result.UpdatedAt = path.UpdatedAt
|
||||
|
||||
case "chat_message":
|
||||
var message models.Message
|
||||
if err := db.First(&message, embedding.ContentID).Error; err != nil {
|
||||
return result, err
|
||||
}
|
||||
if message.IsSensitive {
|
||||
return result, fmt.Errorf("sensitive message excluded from semantic search")
|
||||
}
|
||||
|
||||
result.ID = message.ID
|
||||
result.Type = "chat_message"
|
||||
result.Title = "Chat message"
|
||||
result.Description = compactSemanticText(message.Body, 140)
|
||||
result.Content = message.Body
|
||||
result.CreatedAt = message.CreatedAt
|
||||
result.UpdatedAt = message.UpdatedAt
|
||||
result.URL = fmt.Sprintf("/app/messages?conversationId=%d&messageId=%d", message.ConversationID, message.ID)
|
||||
}
|
||||
|
||||
// Generate highlights (simplified)
|
||||
result.Highlights = generateHighlights(embedding.TextContent, 3)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// generateHighlights generates text highlights
|
||||
func generateHighlights(text string, count int) []string {
|
||||
if text == "" {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
// Simple highlight generation - split into sentences and return first few
|
||||
sentences := strings.Split(text, ".")
|
||||
if len(sentences) > count {
|
||||
sentences = sentences[:count]
|
||||
}
|
||||
|
||||
var highlights []string
|
||||
for _, sentence := range sentences {
|
||||
sentence = strings.TrimSpace(sentence)
|
||||
if len(sentence) > 10 {
|
||||
highlights = append(highlights, sentence+".")
|
||||
}
|
||||
if len(highlights) >= count {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return highlights
|
||||
}
|
||||
|
||||
// reindexUserContent reindexes all content for a user
|
||||
func reindexUserContent(db *gorm.DB, userID uint) {
|
||||
log.Printf("Starting reindexing for user %d", userID)
|
||||
|
||||
// Reindex bookmarks
|
||||
var bookmarks []models.Bookmark
|
||||
db.Where("user_id = ?", userID).Find(&bookmarks)
|
||||
|
||||
for _, bookmark := range bookmarks {
|
||||
text := bookmark.Title + " " + bookmark.Description + " " + bookmark.Content
|
||||
upsertEmbedding(db, userID, "bookmark", bookmark.ID, text)
|
||||
}
|
||||
|
||||
// Tasks
|
||||
var tasks []models.Task
|
||||
db.Where("user_id = ?", userID).Find(&tasks)
|
||||
for _, task := range tasks {
|
||||
text := task.Title + " " + task.Description
|
||||
upsertEmbedding(db, userID, "task", task.ID, text)
|
||||
}
|
||||
|
||||
// Notes
|
||||
var notes []models.Note
|
||||
db.Where("user_id = ?", userID).Find(¬es)
|
||||
for _, note := range notes {
|
||||
if note.IsEncrypted {
|
||||
continue
|
||||
}
|
||||
text := note.Title + " " + note.Description + " " + note.Content
|
||||
upsertEmbedding(db, userID, "note", note.ID, text)
|
||||
}
|
||||
|
||||
// Files
|
||||
var files []models.File
|
||||
db.Where("user_id = ?", userID).Find(&files)
|
||||
for _, file := range files {
|
||||
text := file.OriginalName + " " + file.Description + " " + file.Content
|
||||
upsertEmbedding(db, userID, "file", file.ID, text)
|
||||
}
|
||||
|
||||
// Calendar events
|
||||
var events []models.CalendarEvent
|
||||
db.Where("user_id = ?", userID).Find(&events)
|
||||
for _, event := range events {
|
||||
text := event.Title + " " + event.Description + " " + event.Type + " " + event.Priority
|
||||
upsertEmbedding(db, userID, "calendar_event", event.ID, text)
|
||||
}
|
||||
|
||||
// YouTube bookmarks
|
||||
var videos []models.VideoBookmark
|
||||
db.Where("user_id = ?", userID).Find(&videos)
|
||||
for _, video := range videos {
|
||||
text := video.Title + " " + video.Description + " " + video.Channel + " " + video.URL
|
||||
upsertEmbedding(db, userID, "youtube_video", video.ID, text)
|
||||
}
|
||||
|
||||
// Learning paths
|
||||
var learningPaths []models.LearningPath
|
||||
db.Where("creator_id = ?", userID).Find(&learningPaths)
|
||||
for _, path := range learningPaths {
|
||||
text := path.Title + " " + path.Description + " " + path.Category + " " + path.Difficulty
|
||||
upsertEmbedding(db, userID, "learning_path", path.ID, text)
|
||||
}
|
||||
|
||||
// Chat messages (skip sensitive/vault content)
|
||||
var messages []models.Message
|
||||
db.Model(&models.Message{}).
|
||||
Joins("JOIN conversation_members cm ON cm.conversation_id = messages.conversation_id").
|
||||
Joins("JOIN conversations ON conversations.id = messages.conversation_id").
|
||||
Where("cm.user_id = ?", userID).
|
||||
Where("conversations.type <> ?", models.ConversationTypePasswordVault).
|
||||
Where("messages.deleted_at IS NULL").
|
||||
Find(&messages)
|
||||
for _, message := range messages {
|
||||
if message.IsSensitive {
|
||||
continue
|
||||
}
|
||||
upsertEmbedding(db, userID, "chat_message", message.ID, message.Body)
|
||||
}
|
||||
|
||||
log.Printf("Reindexing completed for user %d", userID)
|
||||
}
|
||||
|
||||
func upsertEmbedding(db *gorm.DB, userID uint, contentType string, contentID uint, text string) {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
|
||||
embedding, err := generateEmbedding(text)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
embeddingJSON, _ := json.Marshal(embedding)
|
||||
|
||||
contentEmbedding := models.ContentEmbedding{
|
||||
ContentType: contentType,
|
||||
ContentID: contentID,
|
||||
Embedding: string(embeddingJSON),
|
||||
Model: "text-embedding-ada-002",
|
||||
Dimensions: len(embedding),
|
||||
TextContent: text,
|
||||
UserID: userID,
|
||||
}
|
||||
|
||||
db.Where("content_type = ? AND content_id = ? AND user_id = ?", contentType, contentID, userID).Delete(&models.ContentEmbedding{})
|
||||
db.Create(&contentEmbedding)
|
||||
}
|
||||
|
||||
func normalizeSemanticContentType(contentType string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(contentType)) {
|
||||
case "bookmarks":
|
||||
return "bookmark"
|
||||
case "tasks":
|
||||
return "task"
|
||||
case "notes":
|
||||
return "note"
|
||||
case "files":
|
||||
return "file"
|
||||
case "calendar_events":
|
||||
return "calendar_event"
|
||||
case "youtube_videos":
|
||||
return "youtube_video"
|
||||
case "learning_paths":
|
||||
return "learning_path"
|
||||
case "chat_messages":
|
||||
return "chat_message"
|
||||
default:
|
||||
return strings.ToLower(strings.TrimSpace(contentType))
|
||||
}
|
||||
}
|
||||
|
||||
func compactSemanticText(text string, limit int) string {
|
||||
text = strings.TrimSpace(text)
|
||||
if len(text) <= limit {
|
||||
return text
|
||||
}
|
||||
if limit < 4 {
|
||||
return text
|
||||
}
|
||||
return strings.TrimSpace(text[:limit-3]) + "..."
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/trackeep/backend/config"
|
||||
@@ -98,3 +100,19 @@ func getDefaultUpdateSettings() UpdateSettings {
|
||||
PrereleaseUpdates: getBoolEnvWithDefault("PRERELEASE_UPDATES", false),
|
||||
}
|
||||
}
|
||||
|
||||
func getBoolEnvWithDefault(key string, defaultValue bool) bool {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
if parsed, err := strconv.ParseBool(value); err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func getEnvWithDefault(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user