Remove AI/chat/search features, add solidtime integration and workspace setup
CI/CD Pipeline / Test (push) Successful in 22m22s
CI/CD Pipeline / Security Scan (push) Successful in 12m37s
CI/CD Pipeline / Build and Push Images (push) Failing after 2m23s

This commit is contained in:
Tomas Dvorak
2026-06-10 10:41:31 +02:00
parent 4dfdd500b4
commit 43acbfab15
51 changed files with 1284 additions and 15385 deletions
+1 -1
View File
@@ -269,7 +269,7 @@ func AdminCreateUser(c *gin.Context) {
return
}
_ = ensureMessagingDefaults(db, user.ID)
user.Password = ""
c.JSON(http.StatusCreated, gin.H{
-811
View File
@@ -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(&note).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")
}
}
-401
View File
@@ -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})
}
-388
View File
@@ -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
}
+1 -1
View File
@@ -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)
-360
View File
@@ -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
}
-181
View File
@@ -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(&notes)
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
+1 -1
View File
@@ -268,7 +268,7 @@ func upsertCentralizedOAuthUser(db *gorm.DB, controllerUser centralizedOAuthUser
return nil, err
}
_ = ensureMessagingDefaults(db, user.ID)
return &user, nil
}
-662
View File
@@ -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(&notes).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)
}
-475
View File
@@ -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,
})
}
-523
View File
@@ -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(&notes).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",
})
}
-184
View File
@@ -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
}
-603
View File
@@ -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(&note, 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(&notes)
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]) + "..."
}
+18
View File
@@ -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
}