mirror of
https://github.com/Dvorinka/Trackeep.git
synced 2026-07-28 21:43:50 +00:00
Remove AI/chat/search features, add solidtime integration and workspace setup
This commit is contained in:
@@ -269,7 +269,7 @@ func AdminCreateUser(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
_ = ensureMessagingDefaults(db, user.ID)
|
||||
|
||||
|
||||
user.Password = ""
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
|
||||
@@ -1,811 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/trackeep/backend/models"
|
||||
"github.com/trackeep/backend/services"
|
||||
)
|
||||
|
||||
// SummarizeContentRequest represents a request to summarize content
|
||||
type SummarizeContentRequest struct {
|
||||
ContentType string `json:"content_type" binding:"required"` // "bookmark", "note", "file"
|
||||
ContentID uint `json:"content_id" binding:"required"`
|
||||
Provider string `json:"provider"` // "mistral", "longcat", "" for default
|
||||
ModelType string `json:"model_type"` // "standard", "thinking", "upgraded_thinking"
|
||||
Options struct {
|
||||
Length string `json:"length"` // "short", "medium", "long"
|
||||
Style string `json:"style"` // "bullet", "paragraph", "executive"
|
||||
IncludeKey bool `json:"include_key"` // Include key points
|
||||
} `json:"options"`
|
||||
}
|
||||
|
||||
// GenerateTaskSuggestionsRequest represents a request for task suggestions
|
||||
type GenerateTaskSuggestionsRequest struct {
|
||||
Context string `json:"context"` // "calendar", "deadlines", "habits", "all"
|
||||
Timeframe string `json:"timeframe"` // "today", "week", "month"
|
||||
Limit int `json:"limit"` // Max number of suggestions
|
||||
Provider string `json:"provider"` // "mistral", "longcat", "" for default
|
||||
ModelType string `json:"model_type"` // "standard", "thinking", "upgraded_thinking"
|
||||
}
|
||||
|
||||
// GenerateTagsRequest represents a request for tag suggestions
|
||||
type GenerateTagsRequest struct {
|
||||
ContentType string `json:"content_type" binding:"required"`
|
||||
ContentID uint `json:"content_id" binding:"required"`
|
||||
Content string `json:"content" binding:"required"`
|
||||
ExistingTag string `json:"existing_tags"`
|
||||
Provider string `json:"provider"` // "mistral", "longcat", "" for default
|
||||
ModelType string `json:"model_type"` // "standard", "thinking", "upgraded_thinking"
|
||||
}
|
||||
|
||||
// GenerateContentRequest represents a request for content generation
|
||||
type GenerateContentRequest struct {
|
||||
Prompt string `json:"prompt" binding:"required"`
|
||||
ContentType string `json:"content_type" binding:"required"`
|
||||
Context string `json:"context"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
MaxLength int `json:"max_length"`
|
||||
Provider string `json:"provider"` // "mistral", "longcat", "" for default
|
||||
ModelType string `json:"model_type"` // "standard", "thinking", "upgraded_thinking"
|
||||
}
|
||||
|
||||
// SummarizeContent generates AI summary for content
|
||||
func SummarizeContent(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
var req SummarizeContentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Get content based on type
|
||||
var content string
|
||||
var title string
|
||||
switch req.ContentType {
|
||||
case "bookmark":
|
||||
var bookmark models.Bookmark
|
||||
if err := models.DB.Where("id = ? AND user_id = ?", req.ContentID, userID).First(&bookmark).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Content not found"})
|
||||
return
|
||||
}
|
||||
content = bookmark.Content
|
||||
title = bookmark.Title
|
||||
case "note":
|
||||
var note models.Note
|
||||
if err := models.DB.Where("id = ? AND user_id = ?", req.ContentID, userID).First(¬e).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Content not found"})
|
||||
return
|
||||
}
|
||||
content = note.Content
|
||||
title = note.Title
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported content type"})
|
||||
return
|
||||
}
|
||||
|
||||
if content == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "No content to summarize"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if summary already exists
|
||||
var existingSummary models.AISummary
|
||||
if err := models.DB.Where("user_id = ? AND content_type = ? AND content_id = ?", userID, req.ContentType, req.ContentID).First(&existingSummary).Error; err == nil {
|
||||
c.JSON(http.StatusOK, existingSummary)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate summary using AI
|
||||
summary, err := generateAISummary(content, title, req.Options, req.Provider, req.ModelType)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate summary: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Save summary
|
||||
aiSummary := models.AISummary{
|
||||
UserID: userID,
|
||||
ContentType: req.ContentType,
|
||||
ContentID: req.ContentID,
|
||||
Title: summary.Title,
|
||||
Summary: summary.Summary,
|
||||
KeyPoints: summary.KeyPoints,
|
||||
Tags: summary.Tags,
|
||||
ReadTime: summary.ReadTime,
|
||||
Complexity: summary.Complexity,
|
||||
ModelUsed: getProviderModel(req.Provider),
|
||||
Confidence: summary.Confidence,
|
||||
LastAnalyzed: time.Now(),
|
||||
}
|
||||
|
||||
if err := models.DB.Create(&aiSummary).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save summary"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, aiSummary)
|
||||
}
|
||||
|
||||
// GetTaskSuggestions generates AI task suggestions
|
||||
func GetTaskSuggestions(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
var req GenerateTaskSuggestionsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Build context from user data
|
||||
contextData, err := buildTaskContext(userID, req.Context, req.Timeframe)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to build context"})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate suggestions
|
||||
suggestions, err := generateTaskSuggestions(contextData, req.Limit, req.Provider, req.ModelType)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate suggestions: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Save suggestions
|
||||
var aiSuggestions []models.AITaskSuggestion
|
||||
for _, suggestion := range suggestions {
|
||||
aiSuggestion := models.AITaskSuggestion{
|
||||
UserID: userID,
|
||||
Title: suggestion.Title,
|
||||
Description: suggestion.Description,
|
||||
Priority: suggestion.Priority,
|
||||
Category: suggestion.Category,
|
||||
Reasoning: suggestion.Reasoning,
|
||||
ContextType: req.Context,
|
||||
ContextData: suggestion.ContextData,
|
||||
Deadline: suggestion.Deadline,
|
||||
EstimatedTime: suggestion.EstimatedTime,
|
||||
ModelUsed: getProviderModel(req.Provider),
|
||||
Confidence: suggestion.Confidence,
|
||||
}
|
||||
models.DB.Create(&aiSuggestion)
|
||||
aiSuggestions = append(aiSuggestions, aiSuggestion)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, aiSuggestions)
|
||||
}
|
||||
|
||||
// GenerateTagSuggestions generates AI tag suggestions
|
||||
func GenerateTagSuggestions(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
var req GenerateTagsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate tags
|
||||
tags, err := generateTagSuggestions(req.Content, req.ExistingTag, req.Provider, req.ModelType)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate tags: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Save suggestion
|
||||
tagSuggestion := models.AITagSuggestion{
|
||||
UserID: userID,
|
||||
ContentType: req.ContentType,
|
||||
ContentID: req.ContentID,
|
||||
SuggestedTags: tags.Suggested,
|
||||
ExistingTags: req.ExistingTag,
|
||||
Relevance: tags.Relevance,
|
||||
ModelUsed: getProviderModel(req.Provider),
|
||||
Confidence: tags.Confidence,
|
||||
}
|
||||
|
||||
if err := models.DB.Create(&tagSuggestion).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save tag suggestion"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, tagSuggestion)
|
||||
}
|
||||
|
||||
// GenerateContent generates AI content
|
||||
func GenerateContent(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
var req GenerateContentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate content
|
||||
content, err := generateAIContent(req.Prompt, req.ContentType, req.Context, req.Temperature, req.MaxLength, req.Provider, req.ModelType)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate content: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Save generation
|
||||
aiContent := models.AIContentGeneration{
|
||||
UserID: userID,
|
||||
Prompt: req.Prompt,
|
||||
ContentType: req.ContentType,
|
||||
Context: req.Context,
|
||||
Title: content.Title,
|
||||
Content: content.Content,
|
||||
WordCount: content.WordCount,
|
||||
ReadTime: content.ReadTime,
|
||||
ModelUsed: getProviderModel(req.Provider),
|
||||
ProcessingMs: content.ProcessingMs,
|
||||
TokenCount: content.TokenCount,
|
||||
Confidence: content.Confidence,
|
||||
Temperature: req.Temperature,
|
||||
}
|
||||
|
||||
if err := models.DB.Create(&aiContent).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save content"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, aiContent)
|
||||
}
|
||||
|
||||
// GetAIProviders returns available AI providers
|
||||
func GetAIProviders(c *gin.Context) {
|
||||
providers := services.GetAvailableProviders()
|
||||
|
||||
providerInfo := make([]map[string]interface{}, 0)
|
||||
for _, provider := range providers {
|
||||
info := map[string]interface{}{
|
||||
"id": string(provider),
|
||||
"name": getProviderDisplayName(provider),
|
||||
}
|
||||
|
||||
// Add model info
|
||||
switch provider {
|
||||
case services.ProviderMistral:
|
||||
standardModel := os.Getenv("MISTRAL_MODEL")
|
||||
thinkingModel := os.Getenv("MISTRAL_MODEL_THINKING")
|
||||
|
||||
info["models"] = []map[string]string{
|
||||
{"id": "standard", "name": standardModel, "type": "Standard"},
|
||||
{"id": "thinking", "name": thinkingModel, "type": "Thinking"},
|
||||
}
|
||||
info["description"] = "Mistral AI - Fast and efficient European AI"
|
||||
info["icon"] = "🇪🇺"
|
||||
|
||||
case services.ProviderLongCat:
|
||||
standardModel := os.Getenv("LONGCAT_MODEL")
|
||||
thinkingModel := os.Getenv("LONGCAT_MODEL_THINKING")
|
||||
upgradedModel := os.Getenv("LONGCAT_MODEL_THINKING_UPGRADED")
|
||||
|
||||
models := []map[string]string{
|
||||
{"id": "standard", "name": standardModel, "type": "Standard"},
|
||||
{"id": "thinking", "name": thinkingModel, "type": "Thinking"},
|
||||
}
|
||||
|
||||
if upgradedModel != "" {
|
||||
models = append(models, map[string]string{"id": "upgraded_thinking", "name": upgradedModel, "type": "Upgraded Thinking"})
|
||||
}
|
||||
|
||||
info["models"] = models
|
||||
info["description"] = "LongCat AI - High-performance AI models"
|
||||
info["icon"] = "🐱"
|
||||
|
||||
case services.ProviderGrok:
|
||||
standardModel := os.Getenv("GROK_MODEL")
|
||||
thinkingModel := os.Getenv("GROK_MODEL_THINKING")
|
||||
|
||||
models := []map[string]string{
|
||||
{"id": "standard", "name": standardModel, "type": "Standard"},
|
||||
}
|
||||
|
||||
if thinkingModel != "" && thinkingModel != standardModel {
|
||||
models = append(models, map[string]string{"id": "thinking", "name": thinkingModel, "type": "Thinking"})
|
||||
}
|
||||
|
||||
info["models"] = models
|
||||
info["description"] = "Grok AI - Real-time information from X"
|
||||
info["icon"] = "🐦"
|
||||
|
||||
case services.ProviderDeepSeek:
|
||||
standardModel := os.Getenv("DEEPSEEK_MODEL")
|
||||
thinkingModel := os.Getenv("DEEPSEEK_MODEL_THINKING")
|
||||
|
||||
models := []map[string]string{
|
||||
{"id": "standard", "name": standardModel, "type": "Standard"},
|
||||
}
|
||||
|
||||
if thinkingModel != "" && thinkingModel != standardModel {
|
||||
models = append(models, map[string]string{"id": "thinking", "name": thinkingModel, "type": "Reasoning"})
|
||||
}
|
||||
|
||||
info["models"] = models
|
||||
info["description"] = "DeepSeek - Advanced reasoning AI"
|
||||
info["icon"] = "🔍"
|
||||
|
||||
case services.ProviderOllama:
|
||||
standardModel := os.Getenv("OLLAMA_MODEL")
|
||||
thinkingModel := os.Getenv("OLLAMA_MODEL_THINKING")
|
||||
|
||||
models := []map[string]string{
|
||||
{"id": "standard", "name": standardModel, "type": "Standard"},
|
||||
}
|
||||
|
||||
if thinkingModel != "" && thinkingModel != standardModel {
|
||||
models = append(models, map[string]string{"id": "thinking", "name": thinkingModel, "type": "Local"})
|
||||
}
|
||||
|
||||
info["models"] = models
|
||||
info["description"] = "Ollama - Local AI models"
|
||||
info["icon"] = "🦙"
|
||||
|
||||
case services.ProviderOpenRouter:
|
||||
standardModel := os.Getenv("OPENROUTER_MODEL")
|
||||
thinkingModel := os.Getenv("OPENROUTER_MODEL_THINKING")
|
||||
|
||||
models := []map[string]string{
|
||||
{"id": "standard", "name": standardModel, "type": "Standard"},
|
||||
}
|
||||
|
||||
if thinkingModel != "" && thinkingModel != standardModel {
|
||||
models = append(models, map[string]string{"id": "thinking", "name": thinkingModel, "type": "Thinking"})
|
||||
}
|
||||
|
||||
info["models"] = models
|
||||
info["description"] = "OpenRouter - Unified access to many models"
|
||||
info["icon"] = "🌀"
|
||||
}
|
||||
|
||||
providerInfo = append(providerInfo, info)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"providers": providerInfo})
|
||||
}
|
||||
|
||||
// Helper function to get display name for provider
|
||||
func getProviderDisplayName(provider services.AIProvider) string {
|
||||
switch provider {
|
||||
case services.ProviderMistral:
|
||||
return "Mistral AI"
|
||||
case services.ProviderLongCat:
|
||||
return "LongCat AI"
|
||||
case services.ProviderGrok:
|
||||
return "Grok AI"
|
||||
case services.ProviderDeepSeek:
|
||||
return "DeepSeek"
|
||||
case services.ProviderOllama:
|
||||
return "Ollama"
|
||||
case services.ProviderOpenRouter:
|
||||
return "OpenRouter"
|
||||
default:
|
||||
return string(provider)
|
||||
}
|
||||
}
|
||||
|
||||
// GetAISummaries retrieves AI summaries for user
|
||||
func GetAISummaries(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
var summaries []models.AISummary
|
||||
models.DB.Where("user_id = ?", userID).Order("created_at desc").Find(&summaries)
|
||||
|
||||
c.JSON(http.StatusOK, summaries)
|
||||
}
|
||||
|
||||
// GetTaskSuggestions retrieves task suggestions for user
|
||||
func GetTaskSuggestionsList(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
var suggestions []models.AITaskSuggestion
|
||||
models.DB.Where("user_id = ? AND accepted = false AND dismissed = false", userID).Order("created_at desc").Find(&suggestions)
|
||||
|
||||
c.JSON(http.StatusOK, suggestions)
|
||||
}
|
||||
|
||||
// AcceptTaskSuggestion accepts a task suggestion
|
||||
func AcceptTaskSuggestion(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
suggestionID := c.Param("id")
|
||||
|
||||
var suggestion models.AITaskSuggestion
|
||||
if err := models.DB.Where("id = ? AND user_id = ?", suggestionID, userID).First(&suggestion).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Suggestion not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Create actual task
|
||||
task := models.Task{
|
||||
UserID: userID,
|
||||
Title: suggestion.Title,
|
||||
Description: suggestion.Description,
|
||||
Priority: models.TaskPriority(suggestion.Priority),
|
||||
Status: models.TaskStatusPending,
|
||||
DueDate: suggestion.Deadline,
|
||||
}
|
||||
|
||||
if err := models.DB.Create(&task).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create task"})
|
||||
return
|
||||
}
|
||||
|
||||
// Mark suggestion as accepted
|
||||
suggestion.Accepted = true
|
||||
models.DB.Save(&suggestion)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Task created successfully", "task_id": task.ID})
|
||||
}
|
||||
|
||||
// DismissTaskSuggestion dismisses a task suggestion
|
||||
func DismissTaskSuggestion(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
suggestionID := c.Param("id")
|
||||
|
||||
var suggestion models.AITaskSuggestion
|
||||
if err := models.DB.Where("id = ? AND user_id = ?", suggestionID, userID).First(&suggestion).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Suggestion not found"})
|
||||
return
|
||||
}
|
||||
|
||||
suggestion.Dismissed = true
|
||||
models.DB.Save(&suggestion)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Suggestion dismissed"})
|
||||
}
|
||||
|
||||
// Helper structs for AI responses
|
||||
type AISummaryResponse struct {
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
KeyPoints string `json:"key_points"`
|
||||
Tags string `json:"tags"`
|
||||
ReadTime int `json:"read_time"`
|
||||
Complexity string `json:"complexity"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
}
|
||||
|
||||
type TaskSuggestionResponse struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Priority string `json:"priority"`
|
||||
Category string `json:"category"`
|
||||
Reasoning string `json:"reasoning"`
|
||||
ContextData string `json:"context_data"`
|
||||
Deadline *time.Time `json:"deadline"`
|
||||
EstimatedTime int `json:"estimated_time"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
}
|
||||
|
||||
type TagSuggestionResponse struct {
|
||||
Suggested string `json:"suggested"`
|
||||
Relevance float64 `json:"relevance"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
}
|
||||
|
||||
type ContentGenerationResponse struct {
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
WordCount int `json:"word_count"`
|
||||
ReadTime int `json:"read_time"`
|
||||
ProcessingMs int64 `json:"processing_ms"`
|
||||
TokenCount int `json:"token_count"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
}
|
||||
|
||||
// AI generation functions (simplified - would call actual AI models)
|
||||
func generateAISummary(content, title string, options struct {
|
||||
Length string `json:"length"`
|
||||
Style string `json:"style"`
|
||||
IncludeKey bool `json:"include_key"`
|
||||
}, provider string, modelType string) (*AISummaryResponse, error) {
|
||||
// Build prompt for summarization
|
||||
prompt := fmt.Sprintf(`Please summarize the following content:
|
||||
Title: %s
|
||||
Content: %s
|
||||
|
||||
Length: %s
|
||||
Style: %s
|
||||
Include key points: %t
|
||||
|
||||
Provide a JSON response with:
|
||||
- title: Brief title
|
||||
- summary: Main summary
|
||||
- key_points: Array of key points (if requested)
|
||||
- tags: Array of relevant tags
|
||||
- read_time: Estimated reading time in minutes
|
||||
- complexity: "low", "medium", or "high"
|
||||
- confidence: Confidence score 0-1`, title, content, options.Length, options.Style, options.IncludeKey)
|
||||
|
||||
messages := []services.Message{
|
||||
{Role: "system", Content: "You are an expert content summarizer. Always respond with valid JSON."},
|
||||
{Role: "user", Content: prompt},
|
||||
}
|
||||
|
||||
// Determine provider
|
||||
aiProvider := services.ProviderMistral // default
|
||||
if provider == "longcat" {
|
||||
aiProvider = services.ProviderLongCat
|
||||
}
|
||||
|
||||
aiService := services.NewAIService(aiProvider)
|
||||
|
||||
req := services.AIRequest{
|
||||
Messages: messages,
|
||||
MaxTokens: 2000,
|
||||
Temperature: 0.3,
|
||||
ModelType: modelType,
|
||||
}
|
||||
|
||||
var resp *services.AIResponse
|
||||
var err error
|
||||
|
||||
// Choose the appropriate method based on model type
|
||||
switch req.ModelType {
|
||||
case "thinking":
|
||||
resp, err = aiService.ChatCompletionWithThinking(req)
|
||||
case "upgraded_thinking":
|
||||
resp, err = aiService.ChatCompletionWithUpgradedThinking(req)
|
||||
default:
|
||||
resp, err = aiService.ChatCompletion(req)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse the response content properly for thinking models
|
||||
actualContent := services.ParseThinkingResponse(resp, aiProvider, modelType)
|
||||
|
||||
var summary AISummaryResponse
|
||||
if err := json.Unmarshal([]byte(actualContent), &summary); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &summary, nil
|
||||
}
|
||||
|
||||
func generateTaskSuggestions(contextData map[string]interface{}, limit int, provider string, modelType string) ([]TaskSuggestionResponse, error) {
|
||||
// Build prompt for task suggestions
|
||||
prompt := fmt.Sprintf(`Based on the following user context, suggest %d tasks:
|
||||
Context: %+v
|
||||
|
||||
Provide a JSON array of task objects with:
|
||||
- title: Task title
|
||||
- description: Task description
|
||||
- priority: "low", "medium", "high", "urgent"
|
||||
- category: Task category
|
||||
- reasoning: Why this task is suggested
|
||||
- context_data: Additional context
|
||||
- deadline: Suggested deadline (ISO date or null)
|
||||
- estimated_time: Estimated time in minutes
|
||||
- confidence: Confidence score 0-1`, limit, contextData)
|
||||
|
||||
messages := []services.Message{
|
||||
{Role: "system", Content: "You are a productivity assistant. Always respond with valid JSON array."},
|
||||
{Role: "user", Content: prompt},
|
||||
}
|
||||
|
||||
// Determine provider
|
||||
aiProvider := services.ProviderMistral // default
|
||||
if provider == "longcat" {
|
||||
aiProvider = services.ProviderLongCat
|
||||
}
|
||||
|
||||
aiService := services.NewAIService(aiProvider)
|
||||
|
||||
req := services.AIRequest{
|
||||
Messages: messages,
|
||||
MaxTokens: 2000,
|
||||
Temperature: 0.7,
|
||||
ModelType: modelType,
|
||||
}
|
||||
|
||||
var resp *services.AIResponse
|
||||
var err error
|
||||
|
||||
// Choose the appropriate method based on model type
|
||||
switch req.ModelType {
|
||||
case "thinking":
|
||||
resp, err = aiService.ChatCompletionWithThinking(req)
|
||||
case "upgraded_thinking":
|
||||
resp, err = aiService.ChatCompletionWithUpgradedThinking(req)
|
||||
default:
|
||||
resp, err = aiService.ChatCompletion(req)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse the response content properly for thinking models
|
||||
actualContent := services.ParseThinkingResponse(resp, aiProvider, modelType)
|
||||
|
||||
var suggestions []TaskSuggestionResponse
|
||||
if err := json.Unmarshal([]byte(actualContent), &suggestions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return suggestions, nil
|
||||
}
|
||||
|
||||
func generateTagSuggestions(content, existingTags string, provider string, modelType string) (*TagSuggestionResponse, error) {
|
||||
prompt := fmt.Sprintf(`Suggest relevant tags for this content:
|
||||
Content: %s
|
||||
Existing tags: %s
|
||||
|
||||
Provide JSON response with:
|
||||
- suggested: Array of suggested tags
|
||||
- relevance: Relevance score 0-1
|
||||
- confidence: Confidence score 0-1`, content, existingTags)
|
||||
|
||||
messages := []services.Message{
|
||||
{Role: "system", Content: "You are a tagging expert. Always respond with valid JSON."},
|
||||
{Role: "user", Content: prompt},
|
||||
}
|
||||
|
||||
// Determine provider
|
||||
aiProvider := services.ProviderMistral // default
|
||||
if provider == "longcat" {
|
||||
aiProvider = services.ProviderLongCat
|
||||
}
|
||||
|
||||
aiService := services.NewAIService(aiProvider)
|
||||
|
||||
req := services.AIRequest{
|
||||
Messages: messages,
|
||||
MaxTokens: 1000,
|
||||
Temperature: 0.5,
|
||||
ModelType: modelType,
|
||||
}
|
||||
|
||||
var resp *services.AIResponse
|
||||
var err error
|
||||
|
||||
// Choose the appropriate method based on model type
|
||||
switch req.ModelType {
|
||||
case "thinking":
|
||||
resp, err = aiService.ChatCompletionWithThinking(req)
|
||||
case "upgraded_thinking":
|
||||
resp, err = aiService.ChatCompletionWithUpgradedThinking(req)
|
||||
default:
|
||||
resp, err = aiService.ChatCompletion(req)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse the response content properly for thinking models
|
||||
actualContent := services.ParseThinkingResponse(resp, aiProvider, modelType)
|
||||
|
||||
var tags TagSuggestionResponse
|
||||
if err := json.Unmarshal([]byte(actualContent), &tags); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &tags, nil
|
||||
}
|
||||
|
||||
func generateAIContent(prompt, contentType, context string, temperature float64, maxLength int, provider string, modelType string) (*ContentGenerationResponse, error) {
|
||||
fullPrompt := fmt.Sprintf(`Generate %s content based on this prompt:
|
||||
%s
|
||||
Additional context: %s
|
||||
Max length: %d words
|
||||
|
||||
Provide JSON response with:
|
||||
- title: Generated title
|
||||
- content: Generated content
|
||||
- word_count: Word count
|
||||
- read_time: Estimated reading time in minutes
|
||||
- confidence: Confidence score 0-1`, contentType, prompt, context, maxLength)
|
||||
|
||||
messages := []services.Message{
|
||||
{Role: "system", Content: "You are a content generation expert. Always respond with valid JSON."},
|
||||
{Role: "user", Content: fullPrompt},
|
||||
}
|
||||
|
||||
// Determine provider
|
||||
aiProvider := services.ProviderMistral // default
|
||||
if provider == "longcat" {
|
||||
aiProvider = services.ProviderLongCat
|
||||
}
|
||||
|
||||
aiService := services.NewAIService(aiProvider)
|
||||
|
||||
// Adjust temperature if provided
|
||||
temp := 0.7
|
||||
if temperature > 0 {
|
||||
temp = temperature
|
||||
}
|
||||
|
||||
req := services.AIRequest{
|
||||
Messages: messages,
|
||||
MaxTokens: maxLength * 2, // Rough estimate
|
||||
Temperature: temp,
|
||||
ModelType: modelType,
|
||||
}
|
||||
|
||||
var resp *services.AIResponse
|
||||
var err error
|
||||
|
||||
// Choose the appropriate method based on model type
|
||||
switch req.ModelType {
|
||||
case "thinking":
|
||||
resp, err = aiService.ChatCompletionWithThinking(req)
|
||||
case "upgraded_thinking":
|
||||
resp, err = aiService.ChatCompletionWithUpgradedThinking(req)
|
||||
default:
|
||||
resp, err = aiService.ChatCompletion(req)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse the response content properly for thinking models
|
||||
actualContent := services.ParseThinkingResponse(resp, aiProvider, modelType)
|
||||
|
||||
var content ContentGenerationResponse
|
||||
if err := json.Unmarshal([]byte(actualContent), &content); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
content.ProcessingMs = 0 // Would track actual processing time
|
||||
content.TokenCount = resp.Usage.TotalTokens
|
||||
|
||||
return &content, nil
|
||||
}
|
||||
|
||||
func buildTaskContext(userID uint, contextType, timeframe string) (map[string]interface{}, error) {
|
||||
ctx := make(map[string]interface{})
|
||||
|
||||
// Get upcoming tasks
|
||||
var tasks []models.Task
|
||||
query := models.DB.Where("user_id = ?", userID)
|
||||
|
||||
if timeframe == "today" {
|
||||
query = query.Where("deadline <= ?", time.Now().AddDate(0, 0, 1))
|
||||
} else if timeframe == "week" {
|
||||
query = query.Where("deadline <= ?", time.Now().AddDate(0, 0, 7))
|
||||
}
|
||||
|
||||
query.Find(&tasks)
|
||||
ctx["tasks"] = tasks
|
||||
|
||||
// Get calendar events
|
||||
var events []models.CalendarEvent
|
||||
models.DB.Where("user_id = ? AND start_time >= ?", userID, time.Now()).Find(&events)
|
||||
ctx["events"] = events
|
||||
|
||||
return ctx, nil
|
||||
}
|
||||
|
||||
// Helper function to get model name based on provider
|
||||
func getProviderModel(provider string) string {
|
||||
switch provider {
|
||||
case "mistral":
|
||||
return os.Getenv("MISTRAL_MODEL")
|
||||
case "longcat":
|
||||
return os.Getenv("LONGCAT_MODEL")
|
||||
case "grok":
|
||||
return os.Getenv("GROK_MODEL")
|
||||
case "deepseek":
|
||||
return os.Getenv("DEEPSEEK_MODEL")
|
||||
case "ollama":
|
||||
return os.Getenv("OLLAMA_MODEL")
|
||||
case "openrouter":
|
||||
return os.Getenv("OPENROUTER_MODEL")
|
||||
default:
|
||||
return os.Getenv("MISTRAL_MODEL")
|
||||
}
|
||||
}
|
||||
@@ -1,401 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/trackeep/backend/models"
|
||||
"github.com/trackeep/backend/services"
|
||||
)
|
||||
|
||||
// AIRecommendationHandler handles AI recommendation endpoints
|
||||
type AIRecommendationHandler struct {
|
||||
db *gorm.DB
|
||||
service *services.AIRecommendationService
|
||||
}
|
||||
|
||||
// NewAIRecommendationHandler creates a new AI recommendation handler
|
||||
func NewAIRecommendationHandler(db *gorm.DB) *AIRecommendationHandler {
|
||||
return &AIRecommendationHandler{
|
||||
db: db,
|
||||
service: services.NewAIRecommendationService(db),
|
||||
}
|
||||
}
|
||||
|
||||
// GetRecommendations returns personalized recommendations for the user
|
||||
func (h *AIRecommendationHandler) GetRecommendations(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
// Parse query parameters
|
||||
recommendationType := c.DefaultQuery("type", "mixed") // content, task, learning, connection, mixed
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "5"))
|
||||
minConfidence, _ := strconv.ParseFloat(c.DefaultQuery("min_confidence", "0.0"), 64)
|
||||
includeDismissed := c.DefaultQuery("include_dismissed", "false") == "true"
|
||||
context := c.Query("context")
|
||||
|
||||
// Create recommendation request
|
||||
req := services.RecommendationRequest{
|
||||
UserID: userID,
|
||||
RecommendationType: recommendationType,
|
||||
Limit: limit,
|
||||
MinConfidence: minConfidence,
|
||||
IncludeDismissed: includeDismissed,
|
||||
Context: context,
|
||||
}
|
||||
|
||||
// Get recommendations
|
||||
recommendations, err := h.service.GetRecommendations(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get recommendations: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"recommendations": recommendations,
|
||||
"count": len(recommendations),
|
||||
"type": recommendationType,
|
||||
})
|
||||
}
|
||||
|
||||
// GetRecommendationStats returns recommendation statistics for the user
|
||||
func (h *AIRecommendationHandler) GetRecommendationStats(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
// Get user preferences
|
||||
var prefs models.UserPreference
|
||||
if err := h.db.Where("user_id = ?", userID).First(&prefs).Error; err != nil {
|
||||
// Create default preferences
|
||||
prefs = models.UserPreference{
|
||||
UserID: userID,
|
||||
EnableRecommendations: true,
|
||||
MinConfidenceThreshold: 0.6,
|
||||
MaxRecommendationsPerDay: 5,
|
||||
MaxAgeHours: 168,
|
||||
}
|
||||
h.db.Create(&prefs)
|
||||
}
|
||||
|
||||
// Get recommendation statistics
|
||||
var stats struct {
|
||||
TotalRecommendations int64 `json:"total_recommendations"`
|
||||
ClickedCount int64 `json:"clicked_count"`
|
||||
DismissedCount int64 `json:"dismissed_count"`
|
||||
FeedbackCount int64 `json:"feedback_count"`
|
||||
Types []struct {
|
||||
Type string `json:"type"`
|
||||
Count int64 `json:"count"`
|
||||
} `json:"types"`
|
||||
Categories []struct {
|
||||
Category string `json:"category"`
|
||||
Count int64 `json:"count"`
|
||||
} `json:"categories"`
|
||||
DailyStats []struct {
|
||||
Date string `json:"date"`
|
||||
Count int64 `json:"count"`
|
||||
} `json:"daily_stats"`
|
||||
}
|
||||
|
||||
// Total recommendations
|
||||
h.db.Model(&models.AIRecommendation{}).Where("user_id = ?", userID).Count(&stats.TotalRecommendations)
|
||||
|
||||
// Clicked and dismissed counts
|
||||
h.db.Model(&models.AIRecommendation{}).Where("user_id = ? AND clicked = ?", userID, true).Count(&stats.ClickedCount)
|
||||
h.db.Model(&models.AIRecommendation{}).Where("user_id = ? AND dismissed = ?", userID, true).Count(&stats.DismissedCount)
|
||||
h.db.Model(&models.AIRecommendation{}).Where("user_id = ? AND feedback != ''", userID).Count(&stats.FeedbackCount)
|
||||
|
||||
// Recommendations by type
|
||||
h.db.Model(&models.AIRecommendation{}).
|
||||
Select("recommendation_type as type, COUNT(*) as count").
|
||||
Where("user_id = ?", userID).
|
||||
Group("recommendation_type").
|
||||
Scan(&stats.Types)
|
||||
|
||||
// Recommendations by category
|
||||
h.db.Model(&models.AIRecommendation{}).
|
||||
Select("category as category, COUNT(*) as count").
|
||||
Where("user_id = ? AND category != ''", userID).
|
||||
Group("category").
|
||||
Order("count DESC").
|
||||
Limit(10).
|
||||
Scan(&stats.Categories)
|
||||
|
||||
// Daily stats for last 30 days
|
||||
h.db.Model(&models.AIRecommendation{}).
|
||||
Select("DATE(created_at) as date, COUNT(*) as count").
|
||||
Where("user_id = ? AND created_at >= NOW() - INTERVAL '30 days'", userID).
|
||||
Group("DATE(created_at)").
|
||||
Order("date ASC").
|
||||
Scan(&stats.DailyStats)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"stats": stats,
|
||||
"preferences": prefs,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdatePreferences updates user recommendation preferences
|
||||
func (h *AIRecommendationHandler) UpdatePreferences(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
var req struct {
|
||||
EnableRecommendations bool `json:"enable_recommendations"`
|
||||
ContentRecommendations bool `json:"content_recommendations"`
|
||||
TaskRecommendations bool `json:"task_recommendations"`
|
||||
LearningRecommendations bool `json:"learning_recommendations"`
|
||||
ConnectionRecommendations bool `json:"connection_recommendations"`
|
||||
MaxRecommendationsPerDay int `json:"max_recommendations_per_day"`
|
||||
PreferredCategories []string `json:"preferred_categories"`
|
||||
BlockedCategories []string `json:"blocked_categories"`
|
||||
PreferredContentTypes []string `json:"preferred_content_types"`
|
||||
MinConfidenceThreshold float64 `json:"min_confidence_threshold"`
|
||||
MaxAgeHours int `json:"max_age_hours"`
|
||||
EnablePersonalization bool `json:"enable_personalization"`
|
||||
EnableFeedbackLearning bool `json:"enable_feedback_learning"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Update or create preferences
|
||||
var prefs models.UserPreference
|
||||
if err := h.db.Where("user_id = ?", userID).First(&prefs).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
prefs = models.UserPreference{UserID: userID}
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Update fields
|
||||
prefs.EnableRecommendations = req.EnableRecommendations
|
||||
prefs.ContentRecommendations = req.ContentRecommendations
|
||||
prefs.TaskRecommendations = req.TaskRecommendations
|
||||
prefs.LearningRecommendations = req.LearningRecommendations
|
||||
prefs.ConnectionRecommendations = req.ConnectionRecommendations
|
||||
prefs.MaxRecommendationsPerDay = req.MaxRecommendationsPerDay
|
||||
prefs.PreferredCategories = req.PreferredCategories
|
||||
prefs.BlockedCategories = req.BlockedCategories
|
||||
prefs.PreferredContentTypes = req.PreferredContentTypes
|
||||
prefs.MinConfidenceThreshold = req.MinConfidenceThreshold
|
||||
prefs.MaxAgeHours = req.MaxAgeHours
|
||||
prefs.EnablePersonalization = req.EnablePersonalization
|
||||
prefs.EnableFeedbackLearning = req.EnableFeedbackLearning
|
||||
|
||||
if err := h.db.Save(&prefs).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update preferences"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Preferences updated successfully",
|
||||
"preferences": prefs,
|
||||
})
|
||||
}
|
||||
|
||||
// RecordInteraction records user interaction with a recommendation
|
||||
func (h *AIRecommendationHandler) RecordInteraction(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
recommendationIDStr := c.Param("id")
|
||||
|
||||
recommendationID, err := strconv.ParseUint(recommendationIDStr, 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid recommendation ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
InteractionType string `json:"interaction_type" binding:"required"` // click, dismiss, feedback, share
|
||||
Context string `json:"context"` // dashboard, search, etc.
|
||||
Feedback string `json:"feedback"` // helpful, not_helpful, irrelevant
|
||||
FeedbackText string `json:"feedback_text"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Record the interaction
|
||||
if err := h.service.RecordInteraction(userID, uint(recommendationID), req.InteractionType, req.Context); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to record interaction"})
|
||||
return
|
||||
}
|
||||
|
||||
// If feedback is provided, update the recommendation
|
||||
if req.Feedback != "" {
|
||||
var recommendation models.AIRecommendation
|
||||
if err := h.db.Where("id = ? AND user_id = ?", uint(recommendationID), userID).First(&recommendation).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Recommendation not found"})
|
||||
return
|
||||
}
|
||||
|
||||
recommendation.Feedback = req.Feedback
|
||||
recommendation.FeedbackText = req.FeedbackText
|
||||
now := time.Now()
|
||||
recommendation.FeedbackAt = &now
|
||||
|
||||
h.db.Save(&recommendation)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Interaction recorded successfully"})
|
||||
}
|
||||
|
||||
// GetRecommendationHistory returns user's recommendation history
|
||||
func (h *AIRecommendationHandler) GetRecommendationHistory(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
// Parse query parameters
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
recommendationType := c.Query("type")
|
||||
status := c.Query("status") // clicked, dismissed, feedback
|
||||
|
||||
// Build query
|
||||
query := h.db.Model(&models.AIRecommendation{}).Where("user_id = ?", userID)
|
||||
|
||||
if recommendationType != "" {
|
||||
query = query.Where("recommendation_type = ?", recommendationType)
|
||||
}
|
||||
|
||||
if status == "clicked" {
|
||||
query = query.Where("clicked = ?", true)
|
||||
} else if status == "dismissed" {
|
||||
query = query.Where("dismissed = ?", true)
|
||||
} else if status == "feedback" {
|
||||
query = query.Where("feedback != ''", userID)
|
||||
}
|
||||
|
||||
// Count total records
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
// Get paginated results
|
||||
offset := (page - 1) * limit
|
||||
var recommendations []models.AIRecommendation
|
||||
query.Order("created_at DESC").Limit(limit).Offset(offset).Find(&recommendations)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"recommendations": recommendations,
|
||||
"pagination": gin.H{
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total": total,
|
||||
"pages": (total + int64(limit) - 1) / int64(limit),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteRecommendation deletes a recommendation
|
||||
func (h *AIRecommendationHandler) DeleteRecommendation(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
recommendationIDStr := c.Param("id")
|
||||
|
||||
recommendationID, err := strconv.ParseUint(recommendationIDStr, 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid recommendation ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// Delete the recommendation (only if it belongs to the user)
|
||||
result := h.db.Where("id = ? AND user_id = ?", uint(recommendationID), userID).Delete(&models.AIRecommendation{})
|
||||
|
||||
if result.RowsAffected == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Recommendation not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Recommendation deleted successfully"})
|
||||
}
|
||||
|
||||
// GetInsights returns AI insights about user patterns
|
||||
func (h *AIRecommendationHandler) GetInsights(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
var insights struct {
|
||||
TopInterests []string `json:"top_interests"`
|
||||
LearningPaths []string `json:"learning_paths"`
|
||||
ProductivityTips []string `json:"productivity_tips"`
|
||||
ConnectionSuggestions []string `json:"connection_suggestions"`
|
||||
Patterns struct {
|
||||
BestProductivityHours []string `json:"best_productivity_hours"`
|
||||
PreferredContentTypes []string `json:"preferred_content_types"`
|
||||
LearningStyle string `json:"learning_style"`
|
||||
} `json:"patterns"`
|
||||
}
|
||||
|
||||
// Get user's top interests from bookmarks and tags
|
||||
var interests []struct {
|
||||
Tag string `json:"tag"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
h.db.Raw(`
|
||||
SELECT unnest(string_to_array(tags, ',')) as tag, COUNT(*) as count
|
||||
FROM bookmarks
|
||||
WHERE user_id = ? AND tags != ''
|
||||
GROUP BY tag
|
||||
ORDER BY count DESC
|
||||
LIMIT 10
|
||||
`, userID).Scan(&interests)
|
||||
|
||||
for _, interest := range interests {
|
||||
insights.TopInterests = append(insights.TopInterests, interest.Tag)
|
||||
}
|
||||
|
||||
// Get learning path suggestions
|
||||
var learningPaths []struct {
|
||||
Category string `json:"category"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
h.db.Raw(`
|
||||
SELECT lp.category, COUNT(*) as count
|
||||
FROM learning_paths lp
|
||||
JOIN enrollments e ON lp.id = e.learning_path_id
|
||||
WHERE e.user_id = ? AND e.progress < 100
|
||||
GROUP BY lp.category
|
||||
ORDER BY count DESC
|
||||
LIMIT 5
|
||||
`, userID).Scan(&learningPaths)
|
||||
|
||||
for _, path := range learningPaths {
|
||||
insights.LearningPaths = append(insights.LearningPaths, path.Category)
|
||||
}
|
||||
|
||||
// Generate productivity tips based on task patterns
|
||||
insights.ProductivityTips = []string{
|
||||
"You complete most tasks in the morning - consider scheduling important work before noon",
|
||||
"Tasks with deadlines are completed 80% faster - set more deadlines",
|
||||
"You're most productive on Tuesdays and Wednesdays",
|
||||
}
|
||||
|
||||
// Generate connection suggestions
|
||||
topInterest := "technology"
|
||||
if len(insights.TopInterests) > 0 {
|
||||
topInterest = insights.TopInterests[0]
|
||||
}
|
||||
|
||||
learningFocus := "productivity"
|
||||
if len(insights.LearningPaths) > 0 {
|
||||
learningFocus = insights.LearningPaths[0]
|
||||
}
|
||||
|
||||
insights.ConnectionSuggestions = []string{
|
||||
"Connect with users who share your interest in " + topInterest,
|
||||
"Join communities focused on " + learningFocus,
|
||||
}
|
||||
|
||||
// Analyze patterns
|
||||
insights.Patterns.BestProductivityHours = []string{"9:00 AM - 11:00 AM", "2:00 PM - 4:00 PM"}
|
||||
insights.Patterns.PreferredContentTypes = []string{"bookmarks", "notes", "courses"}
|
||||
insights.Patterns.LearningStyle = "Visual learner who prefers structured content"
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"insights": insights})
|
||||
}
|
||||
@@ -1,388 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/trackeep/backend/models"
|
||||
)
|
||||
|
||||
// AISettings represents AI provider settings
|
||||
type AISettings struct {
|
||||
Mistral struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKey string `json:"api_key"`
|
||||
Model string `json:"model"`
|
||||
ModelThink string `json:"model_thinking"`
|
||||
} `json:"mistral"`
|
||||
|
||||
Grok struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKey string `json:"api_key"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Model string `json:"model"`
|
||||
ModelThink string `json:"model_thinking"`
|
||||
} `json:"grok"`
|
||||
|
||||
DeepSeek struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKey string `json:"api_key"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Model string `json:"model"`
|
||||
ModelThink string `json:"model_thinking"`
|
||||
} `json:"deepseek"`
|
||||
|
||||
Ollama struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Model string `json:"model"`
|
||||
ModelThink string `json:"model_thinking"`
|
||||
} `json:"ollama"`
|
||||
|
||||
LongCat struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKey string `json:"api_key"`
|
||||
BaseURL string `json:"base_url"`
|
||||
OpenAIEndpoint string `json:"openai_endpoint"`
|
||||
AnthropicEndpoint string `json:"anthropic_endpoint"`
|
||||
Model string `json:"model"`
|
||||
ModelThink string `json:"model_thinking"`
|
||||
ModelThinkUpgraded string `json:"model_thinking_upgraded"`
|
||||
Format string `json:"format"`
|
||||
} `json:"longcat"`
|
||||
|
||||
OpenRouter struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKey string `json:"api_key"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Model string `json:"model"`
|
||||
ModelThink string `json:"model_thinking"`
|
||||
} `json:"openrouter"`
|
||||
}
|
||||
|
||||
// GetAISettings returns current AI settings (with API keys masked)
|
||||
func GetAISettings(c *gin.Context) {
|
||||
// Return settings based on environment variables
|
||||
settings := getDefaultAISettings()
|
||||
c.JSON(http.StatusOK, settings)
|
||||
}
|
||||
|
||||
// UpdateAISettings updates user's AI settings
|
||||
func UpdateAISettings(c *gin.Context) {
|
||||
// Check if demo mode is enabled
|
||||
if os.Getenv("VITE_DEMO_MODE") == "true" {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "AI settings updated successfully (demo mode)"})
|
||||
return
|
||||
}
|
||||
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
var req AISettings
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Get or create user settings
|
||||
var userSettings models.UserAISettings
|
||||
if err := models.DB.Where("user_id = ?", userID).First(&userSettings).Error; err != nil {
|
||||
// Create new settings
|
||||
userSettings.UserID = userID
|
||||
}
|
||||
|
||||
// Update settings
|
||||
userSettings.MistralEnabled = &req.Mistral.Enabled
|
||||
if req.Mistral.APIKey != "" && !isMasked(req.Mistral.APIKey) {
|
||||
userSettings.MistralAPIKey = req.Mistral.APIKey
|
||||
}
|
||||
userSettings.MistralModel = req.Mistral.Model
|
||||
userSettings.MistralModelThinking = req.Mistral.ModelThink
|
||||
|
||||
userSettings.GrokEnabled = &req.Grok.Enabled
|
||||
if req.Grok.APIKey != "" && !isMasked(req.Grok.APIKey) {
|
||||
userSettings.GrokAPIKey = req.Grok.APIKey
|
||||
}
|
||||
userSettings.GrokBaseURL = req.Grok.BaseURL
|
||||
userSettings.GrokModel = req.Grok.Model
|
||||
userSettings.GrokModelThinking = req.Grok.ModelThink
|
||||
|
||||
userSettings.DeepSeekEnabled = &req.DeepSeek.Enabled
|
||||
if req.DeepSeek.APIKey != "" && !isMasked(req.DeepSeek.APIKey) {
|
||||
userSettings.DeepSeekAPIKey = req.DeepSeek.APIKey
|
||||
}
|
||||
userSettings.DeepSeekBaseURL = req.DeepSeek.BaseURL
|
||||
userSettings.DeepSeekModel = req.DeepSeek.Model
|
||||
userSettings.DeepSeekModelThinking = req.DeepSeek.ModelThink
|
||||
|
||||
userSettings.OllamaEnabled = &req.Ollama.Enabled
|
||||
userSettings.OllamaBaseURL = req.Ollama.BaseURL
|
||||
userSettings.OllamaModel = req.Ollama.Model
|
||||
userSettings.OllamaModelThinking = req.Ollama.ModelThink
|
||||
|
||||
userSettings.LongCatEnabled = &req.LongCat.Enabled
|
||||
if req.LongCat.APIKey != "" && !isMasked(req.LongCat.APIKey) {
|
||||
userSettings.LongCatAPIKey = req.LongCat.APIKey
|
||||
}
|
||||
userSettings.LongCatBaseURL = req.LongCat.BaseURL
|
||||
userSettings.LongCatOpenAIEndpoint = req.LongCat.OpenAIEndpoint
|
||||
userSettings.LongCatAnthropicEndpoint = req.LongCat.AnthropicEndpoint
|
||||
userSettings.LongCatModel = req.LongCat.Model
|
||||
userSettings.LongCatModelThinking = req.LongCat.ModelThink
|
||||
userSettings.LongCatModelThinkingUpgraded = req.LongCat.ModelThinkUpgraded
|
||||
userSettings.LongCatFormat = req.LongCat.Format
|
||||
|
||||
userSettings.OpenRouterEnabled = &req.OpenRouter.Enabled
|
||||
if req.OpenRouter.APIKey != "" && !isMasked(req.OpenRouter.APIKey) {
|
||||
userSettings.OpenRouterAPIKey = req.OpenRouter.APIKey
|
||||
}
|
||||
userSettings.OpenRouterBaseURL = req.OpenRouter.BaseURL
|
||||
userSettings.OpenRouterModel = req.OpenRouter.Model
|
||||
userSettings.OpenRouterModelThinking = req.OpenRouter.ModelThink
|
||||
|
||||
// Save to database
|
||||
if userSettings.ID == 0 {
|
||||
if err := models.DB.Create(&userSettings).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save settings"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if err := models.DB.Save(&userSettings).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update settings"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "AI settings updated successfully"})
|
||||
}
|
||||
|
||||
// TestAIConnection tests connection to AI provider
|
||||
func TestAIConnection(c *gin.Context) {
|
||||
// Check if demo mode is enabled
|
||||
if os.Getenv("VITE_DEMO_MODE") == "true" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Connection test successful (demo mode)",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
provider := c.Query("provider")
|
||||
if provider == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Provider is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get user's settings for this provider
|
||||
var userSettings models.UserAISettings
|
||||
if err := models.DB.Where("user_id = ?", userID).First(&userSettings).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "AI settings not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Test connection based on provider
|
||||
var success bool
|
||||
var message string
|
||||
|
||||
switch provider {
|
||||
case "mistral":
|
||||
if userSettings.MistralAPIKey == "" {
|
||||
success = false
|
||||
message = "Mistral API key not configured"
|
||||
} else {
|
||||
// TODO: Implement actual connection test
|
||||
success = true
|
||||
message = "Mistral connection test successful"
|
||||
}
|
||||
case "grok":
|
||||
if userSettings.GrokAPIKey == "" {
|
||||
success = false
|
||||
message = "Grok API key not configured"
|
||||
} else {
|
||||
// TODO: Implement actual connection test
|
||||
success = true
|
||||
message = "Grok connection test successful"
|
||||
}
|
||||
case "deepseek":
|
||||
if userSettings.DeepSeekAPIKey == "" {
|
||||
success = false
|
||||
message = "DeepSeek API key not configured"
|
||||
} else {
|
||||
// TODO: Implement actual connection test
|
||||
success = true
|
||||
message = "DeepSeek connection test successful"
|
||||
}
|
||||
case "longcat":
|
||||
if userSettings.LongCatAPIKey == "" {
|
||||
success = false
|
||||
message = "LongCat API key not configured"
|
||||
} else {
|
||||
// TODO: Implement actual connection test
|
||||
success = true
|
||||
message = "LongCat connection test successful"
|
||||
}
|
||||
case "ollama":
|
||||
if userSettings.OllamaBaseURL == "" {
|
||||
success = false
|
||||
message = "Ollama base URL not configured"
|
||||
} else {
|
||||
// TODO: Implement actual connection test
|
||||
success = true
|
||||
message = "Ollama connection test successful"
|
||||
}
|
||||
case "openrouter":
|
||||
if userSettings.OpenRouterAPIKey == "" {
|
||||
success = false
|
||||
message = "OpenRouter API key not configured"
|
||||
} else {
|
||||
// TODO: Implement actual connection test
|
||||
success = true
|
||||
message = "OpenRouter connection test successful"
|
||||
}
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Unknown provider"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": success,
|
||||
"message": message,
|
||||
})
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
func getDefaultAISettings() AISettings {
|
||||
settings := AISettings{}
|
||||
|
||||
// Simple approach - just set basic values without any complex logic
|
||||
settings.Mistral.Enabled = false
|
||||
settings.Mistral.APIKey = ""
|
||||
settings.Mistral.Model = ""
|
||||
settings.Mistral.ModelThink = ""
|
||||
|
||||
settings.Grok.Enabled = false
|
||||
settings.Grok.APIKey = ""
|
||||
settings.Grok.BaseURL = ""
|
||||
settings.Grok.Model = ""
|
||||
settings.Grok.ModelThink = ""
|
||||
|
||||
settings.DeepSeek.Enabled = false
|
||||
settings.DeepSeek.APIKey = ""
|
||||
settings.DeepSeek.BaseURL = ""
|
||||
settings.DeepSeek.Model = ""
|
||||
settings.DeepSeek.ModelThink = ""
|
||||
|
||||
settings.Ollama.Enabled = false
|
||||
settings.Ollama.BaseURL = ""
|
||||
settings.Ollama.Model = ""
|
||||
settings.Ollama.ModelThink = ""
|
||||
|
||||
settings.LongCat.Enabled = false
|
||||
settings.LongCat.APIKey = ""
|
||||
settings.LongCat.BaseURL = ""
|
||||
settings.LongCat.OpenAIEndpoint = ""
|
||||
settings.LongCat.AnthropicEndpoint = ""
|
||||
settings.LongCat.Model = ""
|
||||
settings.LongCat.ModelThink = ""
|
||||
settings.LongCat.ModelThinkUpgraded = ""
|
||||
settings.LongCat.Format = ""
|
||||
|
||||
settings.OpenRouter.Enabled = false
|
||||
settings.OpenRouter.APIKey = ""
|
||||
settings.OpenRouter.BaseURL = ""
|
||||
settings.OpenRouter.Model = ""
|
||||
settings.OpenRouter.ModelThink = ""
|
||||
|
||||
// Read environment variables to determine enabled providers
|
||||
// This works in both demo and production mode
|
||||
if os.Getenv("MISTRAL_ON") == "true" {
|
||||
settings.Mistral.Enabled = true
|
||||
}
|
||||
if os.Getenv("MISTRAL_API_KEY") != "" {
|
||||
settings.Mistral.APIKey = "********"
|
||||
}
|
||||
settings.Mistral.Model = os.Getenv("MISTRAL_MODEL")
|
||||
settings.Mistral.ModelThink = os.Getenv("MISTRAL_MODEL_THINKING")
|
||||
|
||||
if os.Getenv("LONGCAT_ON") == "true" {
|
||||
settings.LongCat.Enabled = true
|
||||
}
|
||||
if os.Getenv("LONGCAT_API_KEY") != "" {
|
||||
settings.LongCat.APIKey = "********"
|
||||
}
|
||||
settings.LongCat.BaseURL = os.Getenv("LONGCAT_BASE_URL")
|
||||
settings.LongCat.OpenAIEndpoint = os.Getenv("LONGCAT_OPENAI_ENDPOINT")
|
||||
settings.LongCat.AnthropicEndpoint = os.Getenv("LONGCAT_ANTHROPIC_ENDPOINT")
|
||||
settings.LongCat.Model = os.Getenv("LONGCAT_MODEL")
|
||||
settings.LongCat.ModelThink = os.Getenv("LONGCAT_MODEL_THINKING")
|
||||
settings.LongCat.ModelThinkUpgraded = os.Getenv("LONGCAT_MODEL_THINKING_UPGRADED")
|
||||
settings.LongCat.Format = os.Getenv("LONGCAT_FORMAT")
|
||||
|
||||
if os.Getenv("GROK_ON") == "true" {
|
||||
settings.Grok.Enabled = true
|
||||
}
|
||||
if os.Getenv("GROK_API_KEY") != "" {
|
||||
settings.Grok.APIKey = "********"
|
||||
}
|
||||
settings.Grok.BaseURL = os.Getenv("GROK_BASE_URL")
|
||||
settings.Grok.Model = os.Getenv("GROK_MODEL")
|
||||
settings.Grok.ModelThink = os.Getenv("GROK_MODEL_THINKING")
|
||||
|
||||
if os.Getenv("DEEPSEEK_ON") == "true" {
|
||||
settings.DeepSeek.Enabled = true
|
||||
}
|
||||
if os.Getenv("DEEPSEEK_API_KEY") != "" {
|
||||
settings.DeepSeek.APIKey = "********"
|
||||
}
|
||||
settings.DeepSeek.BaseURL = os.Getenv("DEEPSEEK_BASE_URL")
|
||||
settings.DeepSeek.Model = os.Getenv("DEEPSEEK_MODEL")
|
||||
settings.DeepSeek.ModelThink = os.Getenv("DEEPSEEK_MODEL_THINKING")
|
||||
|
||||
if os.Getenv("OLLAMA_ON") == "true" {
|
||||
settings.Ollama.Enabled = true
|
||||
}
|
||||
settings.Ollama.BaseURL = os.Getenv("OLLAMA_BASE_URL")
|
||||
settings.Ollama.Model = os.Getenv("OLLAMA_MODEL")
|
||||
settings.Ollama.ModelThink = os.Getenv("OLLAMA_MODEL_THINKING")
|
||||
|
||||
if os.Getenv("OPENROUTER_ON") == "true" {
|
||||
settings.OpenRouter.Enabled = true
|
||||
}
|
||||
if os.Getenv("OPENROUTER_API_KEY") != "" {
|
||||
settings.OpenRouter.APIKey = "********"
|
||||
}
|
||||
settings.OpenRouter.BaseURL = os.Getenv("OPENROUTER_BASE_URL")
|
||||
settings.OpenRouter.Model = os.Getenv("OPENROUTER_MODEL")
|
||||
settings.OpenRouter.ModelThink = os.Getenv("OPENROUTER_MODEL_THINKING")
|
||||
|
||||
return settings
|
||||
}
|
||||
|
||||
func maskAPIKey(key string) string {
|
||||
if key == "" {
|
||||
return ""
|
||||
}
|
||||
if len(key) <= 8 {
|
||||
return "********"
|
||||
}
|
||||
return key[:4] + "********" + key[len(key)-4:]
|
||||
}
|
||||
|
||||
func isMasked(key string) bool {
|
||||
return key == "" || (len(key) > 8 && key[4:12] == "********")
|
||||
}
|
||||
|
||||
func getBoolEnv(key string, defaultValue bool) bool {
|
||||
value := os.Getenv(key)
|
||||
if value == "" {
|
||||
return defaultValue
|
||||
}
|
||||
boolValue, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return defaultValue
|
||||
}
|
||||
return boolValue
|
||||
}
|
||||
@@ -392,7 +392,7 @@ func Register(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Provision messaging defaults (self chat, password vault, global channels).
|
||||
_ = ensureMessagingDefaults(db, user.ID)
|
||||
|
||||
|
||||
// Generate JWT token
|
||||
token, err := GenerateJWT(user)
|
||||
|
||||
@@ -1,360 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/trackeep/backend/models"
|
||||
"github.com/trackeep/backend/services"
|
||||
)
|
||||
|
||||
// MistralConfig holds configuration for Mistral AI
|
||||
type MistralConfig struct {
|
||||
APIKey string
|
||||
BaseURL string
|
||||
Model string
|
||||
MaxTokens int
|
||||
Temperature float64
|
||||
}
|
||||
|
||||
// ChatRequest represents a chat message request
|
||||
type ChatRequest struct {
|
||||
Message string `json:"message" binding:"required"`
|
||||
SessionID *string `json:"session_id,omitempty"`
|
||||
Context map[string]bool `json:"context,omitempty"` // what data to include
|
||||
Provider string `json:"provider,omitempty"` // "mistral", "longcat", "grok", "deepseek", "ollama", "openrouter"
|
||||
ModelType string `json:"model_type,omitempty"` // "standard", "thinking", "upgraded_thinking"
|
||||
}
|
||||
|
||||
// ChatResponse represents a chat response
|
||||
type ChatResponse struct {
|
||||
ID string `json:"id"`
|
||||
Message string `json:"message"`
|
||||
Role string `json:"role"`
|
||||
SessionID string `json:"session_id"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Model string `json:"model"`
|
||||
TokenUsage TokenUsage `json:"token_usage"`
|
||||
ContextUsed []string `json:"context_used"`
|
||||
}
|
||||
|
||||
// TokenUsage represents token usage information
|
||||
type TokenUsage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
}
|
||||
|
||||
// MistralMessage represents a message for Mistral API
|
||||
type MistralMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// MistralRequest represents a request to Mistral API
|
||||
type MistralRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []MistralMessage `json:"messages"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
}
|
||||
|
||||
// MistralResponse represents a response from Mistral API
|
||||
type MistralResponse struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created"`
|
||||
Model string `json:"model"`
|
||||
Choices []struct {
|
||||
Index int `json:"index"`
|
||||
Message struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
} `json:"choices"`
|
||||
Usage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
|
||||
var mistralConfig = MistralConfig{
|
||||
APIKey: os.Getenv("MISTRAL_API_KEY"),
|
||||
BaseURL: "https://api.mistral.ai/v1",
|
||||
Model: "mistral-small-latest", // Cheap and capable model
|
||||
MaxTokens: 4000,
|
||||
Temperature: 0.7,
|
||||
}
|
||||
|
||||
// GetMistralConfig returns current Mistral configuration
|
||||
func GetMistralConfig() MistralConfig {
|
||||
return mistralConfig
|
||||
}
|
||||
|
||||
// SendMessage handles chat message requests
|
||||
func SendMessage(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
var req ChatRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Get or create session
|
||||
var session models.ChatSession
|
||||
if req.SessionID != nil {
|
||||
if err := models.DB.Where("id = ? AND user_id = ?", *req.SessionID, userID).First(&session).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Create new session
|
||||
session = models.ChatSession{
|
||||
UserID: userID,
|
||||
Title: fmt.Sprintf("Chat %s", time.Now().Format("Jan 2, 3:04 PM")),
|
||||
IncludeBookmarks: true,
|
||||
IncludeTasks: true,
|
||||
IncludeFiles: true,
|
||||
IncludeNotes: true,
|
||||
}
|
||||
if req.Context != nil {
|
||||
session.IncludeBookmarks = req.Context["bookmarks"]
|
||||
session.IncludeTasks = req.Context["tasks"]
|
||||
session.IncludeFiles = req.Context["files"]
|
||||
session.IncludeNotes = req.Context["notes"]
|
||||
}
|
||||
if err := models.DB.Create(&session).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create session"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Save user message
|
||||
userMessage := models.ChatMessage{
|
||||
UserID: userID,
|
||||
SessionID: strconv.Itoa(int(session.ID)),
|
||||
Content: req.Message,
|
||||
Role: "user",
|
||||
}
|
||||
if err := models.DB.Create(&userMessage).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save message"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get conversation history
|
||||
var messages []models.ChatMessage
|
||||
models.DB.Where("session_id = ?", session.ID).Order("created_at asc").Find(&messages)
|
||||
|
||||
// Build context from user data
|
||||
contextData, err := buildUserContext(userID, session)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to build context"})
|
||||
return
|
||||
}
|
||||
|
||||
// Build messages for AI provider (system + history)
|
||||
aiMessages := []services.Message{
|
||||
{
|
||||
Role: "system",
|
||||
Content: buildSystemPrompt(contextData),
|
||||
},
|
||||
}
|
||||
|
||||
// Add conversation history (limit to last 10 messages to manage token count)
|
||||
startIdx := 0
|
||||
if len(messages) > 11 { // system + 10 messages
|
||||
startIdx = len(messages) - 10
|
||||
}
|
||||
for i := startIdx; i < len(messages); i++ {
|
||||
aiMessages = append(aiMessages, services.Message{
|
||||
Role: messages[i].Role,
|
||||
Content: messages[i].Content,
|
||||
})
|
||||
}
|
||||
|
||||
// Determine AI provider
|
||||
aiProvider := services.ProviderMistral
|
||||
switch req.Provider {
|
||||
case "longcat":
|
||||
aiProvider = services.ProviderLongCat
|
||||
case "grok":
|
||||
aiProvider = services.ProviderGrok
|
||||
case "deepseek":
|
||||
aiProvider = services.ProviderDeepSeek
|
||||
case "ollama":
|
||||
aiProvider = services.ProviderOllama
|
||||
case "openrouter":
|
||||
aiProvider = services.ProviderOpenRouter
|
||||
}
|
||||
|
||||
aiService := services.NewAIService(aiProvider)
|
||||
aiReq := services.AIRequest{
|
||||
Messages: aiMessages,
|
||||
MaxTokens: 2000,
|
||||
Temperature: 0.7,
|
||||
ModelType: req.ModelType,
|
||||
}
|
||||
|
||||
// Call AI provider
|
||||
startTime := time.Now()
|
||||
var aiResp *services.AIResponse
|
||||
switch req.ModelType {
|
||||
case "thinking":
|
||||
aiResp, err = aiService.ChatCompletionWithThinking(aiReq)
|
||||
case "upgraded_thinking":
|
||||
aiResp, err = aiService.ChatCompletionWithUpgradedThinking(aiReq)
|
||||
default:
|
||||
aiResp, err = aiService.ChatCompletion(aiReq)
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to call AI: " + err.Error()})
|
||||
return
|
||||
}
|
||||
processingMs := time.Since(startTime).Milliseconds()
|
||||
|
||||
if len(aiResp.Choices) == 0 {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "No response from AI"})
|
||||
return
|
||||
}
|
||||
|
||||
// Extract assistant response, handling thinking models where needed
|
||||
assistantContent := services.ParseThinkingResponse(aiResp, aiProvider, req.ModelType)
|
||||
|
||||
// Save assistant message
|
||||
assistantMessage := models.ChatMessage{
|
||||
UserID: userID,
|
||||
SessionID: strconv.Itoa(int(session.ID)),
|
||||
Content: assistantContent,
|
||||
Role: "assistant",
|
||||
ModelUsed: aiResp.Model,
|
||||
TokenCount: aiResp.Usage.TotalTokens,
|
||||
ProcessingMs: processingMs,
|
||||
ContextItems: getContextItemIDs(contextData),
|
||||
}
|
||||
if err := models.DB.Create(&assistantMessage).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save response"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update session
|
||||
session.MessageCount = len(messages) + 1
|
||||
now := time.Now()
|
||||
session.LastMessageAt = &now
|
||||
models.DB.Save(&session)
|
||||
|
||||
// Return response
|
||||
response := ChatResponse{
|
||||
ID: aiResp.ID,
|
||||
Message: assistantContent,
|
||||
Role: "assistant",
|
||||
SessionID: strconv.Itoa(int(session.ID)),
|
||||
Timestamp: time.Now(),
|
||||
Model: aiResp.Model,
|
||||
TokenUsage: TokenUsage{
|
||||
PromptTokens: aiResp.Usage.PromptTokens,
|
||||
CompletionTokens: aiResp.Usage.CompletionTokens,
|
||||
TotalTokens: aiResp.Usage.TotalTokens,
|
||||
},
|
||||
ContextUsed: getContextItemIDs(contextData),
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// GetSessions retrieves user's chat sessions
|
||||
func GetSessions(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
var sessions []models.ChatSession
|
||||
models.DB.Where("user_id = ?", userID).Order("updated_at desc").Find(&sessions)
|
||||
|
||||
c.JSON(http.StatusOK, sessions)
|
||||
}
|
||||
|
||||
// GetSessionMessages retrieves messages for a specific session
|
||||
func GetSessionMessages(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
sessionID := c.Param("id")
|
||||
|
||||
// Verify session belongs to user
|
||||
var session models.ChatSession
|
||||
if err := models.DB.Where("id = ? AND user_id = ?", sessionID, userID).First(&session).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var messages []models.ChatMessage
|
||||
models.DB.Where("session_id = ?", sessionID).Order("created_at asc").Find(&messages)
|
||||
|
||||
c.JSON(http.StatusOK, messages)
|
||||
}
|
||||
|
||||
// DeleteSession deletes a chat session and its messages
|
||||
func DeleteSession(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
sessionID := c.Param("id")
|
||||
|
||||
// Verify session belongs to user
|
||||
var session models.ChatSession
|
||||
if err := models.DB.Where("id = ? AND user_id = ?", sessionID, userID).First(&session).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Delete messages first
|
||||
models.DB.Where("session_id = ?", sessionID).Delete(&models.ChatMessage{})
|
||||
|
||||
// Delete session
|
||||
models.DB.Delete(&session)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Session deleted"})
|
||||
}
|
||||
|
||||
func callMistral(messages []MistralMessage) (*MistralResponse, error) {
|
||||
reqBody := MistralRequest{
|
||||
Model: mistralConfig.Model,
|
||||
Messages: messages,
|
||||
MaxTokens: mistralConfig.MaxTokens,
|
||||
Temperature: mistralConfig.Temperature,
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", mistralConfig.BaseURL+"/chat/completions", strings.NewReader(string(jsonData)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+mistralConfig.APIKey)
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("Mistral API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var mistralResp MistralResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&mistralResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &mistralResp, nil
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/trackeep/backend/models"
|
||||
)
|
||||
|
||||
// UserContext represents the contextual data available to the AI
|
||||
type UserContext struct {
|
||||
Bookmarks []models.Bookmark
|
||||
Tasks []models.Task
|
||||
Files []models.File
|
||||
Notes []models.Note
|
||||
}
|
||||
|
||||
// buildUserContext gathers user data based on session configuration
|
||||
func buildUserContext(userID uint, session models.ChatSession) (*UserContext, error) {
|
||||
context := &UserContext{}
|
||||
|
||||
// Get bookmarks
|
||||
if session.IncludeBookmarks {
|
||||
var bookmarks []models.Bookmark
|
||||
models.DB.Where("user_id = ?", userID).Limit(20).Order("updated_at desc").Find(&bookmarks)
|
||||
context.Bookmarks = bookmarks
|
||||
}
|
||||
|
||||
// Get tasks
|
||||
if session.IncludeTasks {
|
||||
var tasks []models.Task
|
||||
models.DB.Where("user_id = ?", userID).Limit(20).Order("updated_at desc").Find(&tasks)
|
||||
context.Tasks = tasks
|
||||
}
|
||||
|
||||
// Get files
|
||||
if session.IncludeFiles {
|
||||
var files []models.File
|
||||
models.DB.Where("user_id = ?", userID).Limit(20).Order("updated_at desc").Find(&files)
|
||||
context.Files = files
|
||||
}
|
||||
|
||||
// Get notes
|
||||
if session.IncludeNotes {
|
||||
var notes []models.Note
|
||||
models.DB.Where("user_id = ?", userID).Limit(20).Order("updated_at desc").Find(¬es)
|
||||
context.Notes = notes
|
||||
}
|
||||
|
||||
return context, nil
|
||||
}
|
||||
|
||||
// buildSystemPrompt creates a system prompt with user context
|
||||
func buildSystemPrompt(context *UserContext) string {
|
||||
prompt := `You are a helpful AI assistant for Trackeep, a personal productivity and knowledge management platform.
|
||||
You have access to the user's personal data including bookmarks, tasks, files, and notes.
|
||||
Your role is to help them organize, find information, and manage their digital life effectively.
|
||||
|
||||
Key capabilities:
|
||||
- Help find specific bookmarks, tasks, or notes
|
||||
- Suggest organization strategies
|
||||
- Answer questions about their saved content
|
||||
- Help with task planning and prioritization
|
||||
- Assist with learning progress tracking
|
||||
|
||||
Be helpful, concise, and actionable. If you reference specific items, mention their titles or key details.
|
||||
|
||||
--- USER DATA ---`
|
||||
|
||||
// Add bookmarks context
|
||||
if len(context.Bookmarks) > 0 {
|
||||
prompt += "\n\nBOOKMARKS:\n"
|
||||
for i, bookmark := range context.Bookmarks {
|
||||
if i >= 10 { // Limit to prevent token overflow
|
||||
prompt += "... and " + strconv.Itoa(len(context.Bookmarks)-10) + " more bookmarks\n"
|
||||
break
|
||||
}
|
||||
prompt += fmt.Sprintf("- %s: %s", bookmark.Title, bookmark.URL)
|
||||
if bookmark.Description != "" {
|
||||
prompt += " (" + bookmark.Description + ")"
|
||||
}
|
||||
if bookmark.IsFavorite {
|
||||
prompt += " ⭐"
|
||||
}
|
||||
prompt += "\n"
|
||||
}
|
||||
}
|
||||
|
||||
// Add tasks context
|
||||
if len(context.Tasks) > 0 {
|
||||
prompt += "\n\nTASKS:\n"
|
||||
for i, task := range context.Tasks {
|
||||
if i >= 10 {
|
||||
prompt += "... and " + strconv.Itoa(len(context.Tasks)-10) + " more tasks\n"
|
||||
break
|
||||
}
|
||||
status := string(task.Status)
|
||||
priority := string(task.Priority)
|
||||
prompt += fmt.Sprintf("- [%s] %s (Priority: %s)", status, task.Title, priority)
|
||||
if task.DueDate != nil {
|
||||
prompt += " Due: " + task.DueDate.Format("Jan 2")
|
||||
}
|
||||
prompt += "\n"
|
||||
}
|
||||
}
|
||||
|
||||
// Add files context
|
||||
if len(context.Files) > 0 {
|
||||
prompt += "\n\nFILES:\n"
|
||||
for i, file := range context.Files {
|
||||
if i >= 10 {
|
||||
prompt += "... and " + strconv.Itoa(len(context.Files)-10) + " more files\n"
|
||||
break
|
||||
}
|
||||
prompt += fmt.Sprintf("- %s (%s, %s)", file.OriginalName, file.FileType, formatFileSize(file.FileSize))
|
||||
if file.Description != "" {
|
||||
prompt += " - " + file.Description
|
||||
}
|
||||
prompt += "\n"
|
||||
}
|
||||
}
|
||||
|
||||
// Add notes context
|
||||
if len(context.Notes) > 0 {
|
||||
prompt += "\n\nNOTES:\n"
|
||||
for i, note := range context.Notes {
|
||||
if i >= 10 {
|
||||
prompt += "... and " + strconv.Itoa(len(context.Notes)-10) + " more notes\n"
|
||||
break
|
||||
}
|
||||
prompt += fmt.Sprintf("- %s", note.Title)
|
||||
if note.Description != "" {
|
||||
prompt += " - " + note.Description
|
||||
}
|
||||
if note.IsPinned {
|
||||
prompt += " 📌"
|
||||
}
|
||||
prompt += "\n"
|
||||
}
|
||||
}
|
||||
|
||||
prompt += "\n--- END USER DATA ---\n\nNow respond to the user's message based on this context."
|
||||
return prompt
|
||||
}
|
||||
|
||||
// getContextItemIDs extracts IDs from context for tracking
|
||||
func getContextItemIDs(context *UserContext) []string {
|
||||
var ids []string
|
||||
|
||||
for _, bookmark := range context.Bookmarks {
|
||||
ids = append(ids, "bookmark:"+strconv.Itoa(int(bookmark.ID)))
|
||||
}
|
||||
|
||||
for _, task := range context.Tasks {
|
||||
ids = append(ids, "task:"+strconv.Itoa(int(task.ID)))
|
||||
}
|
||||
|
||||
for _, file := range context.Files {
|
||||
ids = append(ids, "file:"+strconv.Itoa(int(file.ID)))
|
||||
}
|
||||
|
||||
for _, note := range context.Notes {
|
||||
ids = append(ids, "note:"+strconv.Itoa(int(note.ID)))
|
||||
}
|
||||
|
||||
return ids
|
||||
}
|
||||
|
||||
// formatFileSize formats file size in human readable format
|
||||
func formatFileSize(bytes int64) string {
|
||||
const unit = 1024
|
||||
if bytes < unit {
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for n := bytes / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -268,7 +268,7 @@ func upsertCentralizedOAuthUser(db *gorm.DB, controllerUser centralizedOAuthUser
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_ = ensureMessagingDefaults(db, user.ID)
|
||||
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
@@ -1,662 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/trackeep/backend/models"
|
||||
)
|
||||
|
||||
// SavedSearchRequest represents the request payload for creating/updating saved searches
|
||||
type SavedSearchRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Query string `json:"query" binding:"required"`
|
||||
Filters map[string]interface{} `json:"filters"`
|
||||
Alert bool `json:"alert"`
|
||||
IsPublic bool `json:"is_public"`
|
||||
Description string `json:"description"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
// SavedSearchResponse represents the response payload for saved searches
|
||||
type SavedSearchResponse struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Query string `json:"query"`
|
||||
Filters map[string]interface{} `json:"filters"`
|
||||
Alert bool `json:"alert"`
|
||||
LastRun *time.Time `json:"last_run"`
|
||||
RunCount int `json:"run_count"`
|
||||
IsPublic bool `json:"is_public"`
|
||||
Description string `json:"description"`
|
||||
Tags []models.SavedSearchTag `json:"tags"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// CreateSavedSearch handles POST /api/v1/search/saved
|
||||
func CreateSavedSearch(c *gin.Context) {
|
||||
var req SavedSearchRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
userID := c.GetUint("user_id")
|
||||
if userID == 0 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
// Serialize filters to JSON
|
||||
filtersJSON, err := json.Marshal(req.Filters)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid filters format"})
|
||||
return
|
||||
}
|
||||
|
||||
// Create saved search
|
||||
savedSearch := models.SavedSearch{
|
||||
UserID: userID,
|
||||
Name: req.Name,
|
||||
Query: req.Query,
|
||||
Filters: string(filtersJSON),
|
||||
Alert: req.Alert,
|
||||
IsPublic: req.IsPublic,
|
||||
RunCount: 0,
|
||||
Tags: []models.SavedSearchTag{},
|
||||
}
|
||||
|
||||
// Handle tags
|
||||
if len(req.Tags) > 0 {
|
||||
db := c.MustGet("db").(*gorm.DB)
|
||||
for _, tagName := range req.Tags {
|
||||
var tag models.SavedSearchTag
|
||||
if err := db.Where("name = ?", tagName).First(&tag).Error; err != nil {
|
||||
// Create new tag if it doesn't exist
|
||||
tag = models.SavedSearchTag{
|
||||
Name: tagName,
|
||||
Color: "#3b82f6", // Default blue color
|
||||
}
|
||||
db.Create(&tag)
|
||||
}
|
||||
savedSearch.Tags = append(savedSearch.Tags, tag)
|
||||
}
|
||||
}
|
||||
|
||||
db := c.MustGet("db").(*gorm.DB)
|
||||
if err := db.Create(&savedSearch).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create saved search"})
|
||||
return
|
||||
}
|
||||
|
||||
// Load tags for response
|
||||
db.Preload("Tags").First(&savedSearch, savedSearch.ID)
|
||||
|
||||
response := SavedSearchResponse{
|
||||
ID: savedSearch.ID,
|
||||
Name: savedSearch.Name,
|
||||
Query: savedSearch.Query,
|
||||
Alert: savedSearch.Alert,
|
||||
LastRun: savedSearch.LastRun,
|
||||
RunCount: savedSearch.RunCount,
|
||||
IsPublic: savedSearch.IsPublic,
|
||||
Description: savedSearch.Description,
|
||||
Tags: savedSearch.Tags,
|
||||
CreatedAt: savedSearch.CreatedAt,
|
||||
UpdatedAt: savedSearch.UpdatedAt,
|
||||
}
|
||||
|
||||
// Parse filters back to map
|
||||
json.Unmarshal([]byte(savedSearch.Filters), &response.Filters)
|
||||
|
||||
c.JSON(http.StatusCreated, response)
|
||||
}
|
||||
|
||||
// GetUserSavedSearches handles GET /api/v1/search/saved
|
||||
func GetUserSavedSearches(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
if userID == 0 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
db := c.MustGet("db").(*gorm.DB)
|
||||
|
||||
// Parse query parameters
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
tagFilter := c.Query("tag")
|
||||
alertFilter := c.Query("alert")
|
||||
|
||||
offset := (page - 1) * limit
|
||||
|
||||
query := db.Model(&models.SavedSearch{}).Where("user_id = ? OR is_public = ?", userID, true)
|
||||
|
||||
// Apply filters
|
||||
if tagFilter != "" {
|
||||
query = query.Joins("JOIN saved_search_tags ON saved_search_tags.id = saved_searches.id").
|
||||
Joins("JOIN saved_search_tag_saved_searches ON saved_search_tag_saved_searches.saved_search_id = saved_searches.id").
|
||||
Joins("JOIN saved_search_tags t ON t.id = saved_search_tag_saved_searches.saved_search_tag_id").
|
||||
Where("t.name = ?", tagFilter)
|
||||
}
|
||||
|
||||
if alertFilter == "true" {
|
||||
query = query.Where("alert = ?", true)
|
||||
} else if alertFilter == "false" {
|
||||
query = query.Where("alert = ?", false)
|
||||
}
|
||||
|
||||
var savedSearches []models.SavedSearch
|
||||
var total int64
|
||||
|
||||
if err := query.Preload("Tags").Count(&total).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to count saved searches"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := query.Preload("Tags").Offset(offset).Limit(limit).Order("created_at DESC").Find(&savedSearches).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch saved searches"})
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to response format
|
||||
var responses []SavedSearchResponse
|
||||
for _, ss := range savedSearches {
|
||||
var filters map[string]interface{}
|
||||
json.Unmarshal([]byte(ss.Filters), &filters)
|
||||
|
||||
response := SavedSearchResponse{
|
||||
ID: ss.ID,
|
||||
Name: ss.Name,
|
||||
Query: ss.Query,
|
||||
Filters: filters,
|
||||
Alert: ss.Alert,
|
||||
LastRun: ss.LastRun,
|
||||
RunCount: ss.RunCount,
|
||||
IsPublic: ss.IsPublic,
|
||||
Description: ss.Description,
|
||||
Tags: ss.Tags,
|
||||
CreatedAt: ss.CreatedAt,
|
||||
UpdatedAt: ss.UpdatedAt,
|
||||
}
|
||||
responses = append(responses, response)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"saved_searches": responses,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
})
|
||||
}
|
||||
|
||||
// GetSavedSearch handles GET /api/v1/search/saved/:id
|
||||
func GetSavedSearch(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
if userID == 0 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid saved search ID"})
|
||||
return
|
||||
}
|
||||
|
||||
db := c.MustGet("db").(*gorm.DB)
|
||||
var savedSearch models.SavedSearch
|
||||
|
||||
if err := db.Preload("Tags").Where("id = ? AND (user_id = ? OR is_public = ?)", id, userID, true).First(&savedSearch).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Saved search not found"})
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch saved search"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var filters map[string]interface{}
|
||||
json.Unmarshal([]byte(savedSearch.Filters), &filters)
|
||||
|
||||
response := SavedSearchResponse{
|
||||
ID: savedSearch.ID,
|
||||
Name: savedSearch.Name,
|
||||
Query: savedSearch.Query,
|
||||
Filters: filters,
|
||||
Alert: savedSearch.Alert,
|
||||
LastRun: savedSearch.LastRun,
|
||||
RunCount: savedSearch.RunCount,
|
||||
IsPublic: savedSearch.IsPublic,
|
||||
Description: savedSearch.Description,
|
||||
Tags: savedSearch.Tags,
|
||||
CreatedAt: savedSearch.CreatedAt,
|
||||
UpdatedAt: savedSearch.UpdatedAt,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// UpdateSavedSearch handles PUT /api/v1/search/saved/:id
|
||||
func UpdateSavedSearch(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
if userID == 0 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid saved search ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req SavedSearchRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
db := c.MustGet("db").(*gorm.DB)
|
||||
var savedSearch models.SavedSearch
|
||||
|
||||
if err := db.Where("id = ? AND user_id = ?", id, userID).First(&savedSearch).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Saved search not found"})
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch saved search"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Update fields
|
||||
savedSearch.Name = req.Name
|
||||
savedSearch.Query = req.Query
|
||||
savedSearch.Alert = req.Alert
|
||||
savedSearch.IsPublic = req.IsPublic
|
||||
savedSearch.Description = req.Description
|
||||
|
||||
// Update filters
|
||||
filtersJSON, err := json.Marshal(req.Filters)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid filters format"})
|
||||
return
|
||||
}
|
||||
savedSearch.Filters = string(filtersJSON)
|
||||
|
||||
// Update tags
|
||||
if err := db.Model(&savedSearch).Association("Tags").Clear(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clear tags"})
|
||||
return
|
||||
}
|
||||
|
||||
for _, tagName := range req.Tags {
|
||||
var tag models.SavedSearchTag
|
||||
if err := db.Where("name = ?", tagName).First(&tag).Error; err != nil {
|
||||
tag = models.SavedSearchTag{
|
||||
Name: tagName,
|
||||
Color: "#3b82f6",
|
||||
}
|
||||
db.Create(&tag)
|
||||
}
|
||||
if err := db.Model(&savedSearch).Association("Tags").Append(&tag); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add tag"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.Save(&savedSearch).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update saved search"})
|
||||
return
|
||||
}
|
||||
|
||||
// Load updated data
|
||||
db.Preload("Tags").First(&savedSearch, savedSearch.ID)
|
||||
|
||||
var filters map[string]interface{}
|
||||
json.Unmarshal([]byte(savedSearch.Filters), &filters)
|
||||
|
||||
response := SavedSearchResponse{
|
||||
ID: savedSearch.ID,
|
||||
Name: savedSearch.Name,
|
||||
Query: savedSearch.Query,
|
||||
Filters: filters,
|
||||
Alert: savedSearch.Alert,
|
||||
LastRun: savedSearch.LastRun,
|
||||
RunCount: savedSearch.RunCount,
|
||||
IsPublic: savedSearch.IsPublic,
|
||||
Description: savedSearch.Description,
|
||||
Tags: savedSearch.Tags,
|
||||
CreatedAt: savedSearch.CreatedAt,
|
||||
UpdatedAt: savedSearch.UpdatedAt,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// DeleteSavedSearch handles DELETE /api/v1/search/saved/:id
|
||||
func DeleteSavedSearch(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
if userID == 0 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid saved search ID"})
|
||||
return
|
||||
}
|
||||
|
||||
db := c.MustGet("db").(*gorm.DB)
|
||||
result := db.Where("id = ? AND user_id = ?", id, userID).Delete(&models.SavedSearch{})
|
||||
|
||||
if result.Error != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete saved search"})
|
||||
return
|
||||
}
|
||||
|
||||
if result.RowsAffected == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Saved search not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Saved search deleted successfully"})
|
||||
}
|
||||
|
||||
// RunSavedSearch handles POST /api/v1/search/saved/:id/run
|
||||
func RunSavedSearch(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
if userID == 0 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid saved search ID"})
|
||||
return
|
||||
}
|
||||
|
||||
db := c.MustGet("db").(*gorm.DB)
|
||||
var savedSearch models.SavedSearch
|
||||
|
||||
if err := db.Where("id = ? AND (user_id = ? OR is_public = ?)", id, userID, true).First(&savedSearch).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Saved search not found"})
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch saved search"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Parse filters
|
||||
var filters map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(savedSearch.Filters), &filters); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to parse filters"})
|
||||
return
|
||||
}
|
||||
|
||||
// Create search request based on saved search
|
||||
searchReq := map[string]interface{}{
|
||||
"query": savedSearch.Query,
|
||||
}
|
||||
|
||||
// Merge filters
|
||||
for k, v := range filters {
|
||||
searchReq[k] = v
|
||||
}
|
||||
|
||||
// Perform the search using existing enhanced search logic
|
||||
// This is a simplified version - in production, you'd want to reuse the actual search handler
|
||||
searchResults, err := performSearchFromSavedSearch(searchReq, userID, db)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to execute search"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update saved search run statistics
|
||||
now := time.Now()
|
||||
savedSearch.LastRun = &now
|
||||
savedSearch.RunCount++
|
||||
db.Save(&savedSearch)
|
||||
|
||||
// Log search analytics
|
||||
logSearchAnalytics(userID, savedSearch.Query, savedSearch.Filters, len(searchResults), db)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"results": searchResults,
|
||||
"query": savedSearch.Query,
|
||||
"filters": filters,
|
||||
"total": len(searchResults),
|
||||
"saved_search": gin.H{
|
||||
"id": savedSearch.ID,
|
||||
"name": savedSearch.Name,
|
||||
"last_run": savedSearch.LastRun,
|
||||
"run_count": savedSearch.RunCount,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetSavedSearchTags handles GET /api/v1/search/saved/tags
|
||||
func GetSavedSearchTags(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
if userID == 0 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
db := c.MustGet("db").(*gorm.DB)
|
||||
var tags []models.SavedSearchTag
|
||||
|
||||
if err := db.Order("name").Find(&tags).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch tags"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"tags": tags})
|
||||
}
|
||||
|
||||
// Helper function to perform search from saved search
|
||||
func performSearchFromSavedSearch(searchReq map[string]interface{}, userID uint, db *gorm.DB) ([]interface{}, error) {
|
||||
// Build search filters from the request
|
||||
filters := SearchFilters{
|
||||
Query: getStringValue(searchReq, "query"),
|
||||
ContentType: getStringValue(searchReq, "content_type"),
|
||||
Limit: getIntValue(searchReq, "limit", 20),
|
||||
Offset: getIntValue(searchReq, "offset", 0),
|
||||
}
|
||||
|
||||
// Parse tags if present
|
||||
if tags, ok := searchReq["tags"].([]interface{}); ok {
|
||||
for _, tag := range tags {
|
||||
if tagStr, ok := tag.(string); ok {
|
||||
filters.Tags = append(filters.Tags, tagStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse date range if present
|
||||
if dateRange, ok := searchReq["date_range"].(map[string]interface{}); ok {
|
||||
if startStr, ok := dateRange["start"].(string); ok && startStr != "" {
|
||||
if startTime, err := time.Parse("2006-01-02", startStr); err == nil {
|
||||
filters.DateRange.Start = startTime
|
||||
}
|
||||
}
|
||||
if endStr, ok := dateRange["end"].(string); ok && endStr != "" {
|
||||
if endTime, err := time.Parse("2006-01-02", endStr); err == nil {
|
||||
filters.DateRange.End = endTime
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse boolean filters
|
||||
if isFavorite, ok := searchReq["is_favorite"].(bool); ok {
|
||||
filters.IsFavorite = &isFavorite
|
||||
}
|
||||
if isRead, ok := searchReq["is_read"].(bool); ok {
|
||||
filters.IsRead = &isRead
|
||||
}
|
||||
if isPublic, ok := searchReq["is_public"].(bool); ok {
|
||||
filters.IsPublic = &isPublic
|
||||
}
|
||||
|
||||
// Perform the search using existing enhanced search logic
|
||||
results, err := performEnhancedSearch(filters, userID, db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Convert results to interface slice
|
||||
var interfaceResults []interface{}
|
||||
for _, result := range results {
|
||||
interfaceResults = append(interfaceResults, result)
|
||||
}
|
||||
|
||||
return interfaceResults, nil
|
||||
}
|
||||
|
||||
// Helper function to perform enhanced search (reused from search_enhanced.go)
|
||||
func performEnhancedSearch(filters SearchFilters, userID uint, db *gorm.DB) ([]SearchResult, error) {
|
||||
var results []SearchResult
|
||||
|
||||
// Search bookmarks
|
||||
if filters.ContentType == "all" || filters.ContentType == "bookmarks" {
|
||||
var bookmarks []models.Bookmark
|
||||
query := db.Where("user_id = ?", userID)
|
||||
|
||||
// Apply text search
|
||||
if filters.Query != "" {
|
||||
query = query.Where("title ILIKE ? OR description ILIKE ? OR content ILIKE ?",
|
||||
"%"+filters.Query+"%", "%"+filters.Query+"%", "%"+filters.Query+"%")
|
||||
}
|
||||
|
||||
// Apply filters
|
||||
if filters.IsFavorite != nil {
|
||||
query = query.Where("is_favorite = ?", *filters.IsFavorite)
|
||||
}
|
||||
|
||||
if err := query.Limit(filters.Limit).Offset(filters.Offset).Find(&bookmarks).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, bookmark := range bookmarks {
|
||||
result := SearchResult{
|
||||
ID: bookmark.ID,
|
||||
Type: "bookmark",
|
||||
Title: bookmark.Title,
|
||||
Description: bookmark.Description,
|
||||
Content: bookmark.Content,
|
||||
CreatedAt: bookmark.CreatedAt,
|
||||
UpdatedAt: bookmark.UpdatedAt,
|
||||
URL: bookmark.URL,
|
||||
IsFavorite: bookmark.IsFavorite,
|
||||
IsRead: bookmark.IsRead,
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
}
|
||||
|
||||
// Search tasks
|
||||
if filters.ContentType == "all" || filters.ContentType == "tasks" {
|
||||
var tasks []models.Task
|
||||
query := db.Where("user_id = ?", userID)
|
||||
|
||||
if filters.Query != "" {
|
||||
query = query.Where("title ILIKE ? OR description ILIKE ?",
|
||||
"%"+filters.Query+"%", "%"+filters.Query+"%")
|
||||
}
|
||||
|
||||
if err := query.Limit(filters.Limit).Offset(filters.Offset).Find(&tasks).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, task := range tasks {
|
||||
result := SearchResult{
|
||||
ID: task.ID,
|
||||
Type: "task",
|
||||
Title: task.Title,
|
||||
Description: task.Description,
|
||||
CreatedAt: task.CreatedAt,
|
||||
UpdatedAt: task.UpdatedAt,
|
||||
Status: string(task.Status),
|
||||
Priority: string(task.Priority),
|
||||
DueDate: task.DueDate,
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
}
|
||||
|
||||
// Search notes
|
||||
if filters.ContentType == "all" || filters.ContentType == "notes" {
|
||||
var notes []models.Note
|
||||
query := db.Where("user_id = ?", userID)
|
||||
|
||||
if filters.Query != "" {
|
||||
query = query.Where("title ILIKE ? OR content ILIKE ?",
|
||||
"%"+filters.Query+"%", "%"+filters.Query+"%")
|
||||
}
|
||||
|
||||
if err := query.Limit(filters.Limit).Offset(filters.Offset).Find(¬es).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, note := range notes {
|
||||
result := SearchResult{
|
||||
ID: note.ID,
|
||||
Type: "note",
|
||||
Title: note.Title,
|
||||
Description: note.Content[:min(200, len(note.Content))],
|
||||
Content: note.Content,
|
||||
CreatedAt: note.CreatedAt,
|
||||
UpdatedAt: note.UpdatedAt,
|
||||
IsPublic: note.IsPublic,
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
func getStringValue(m map[string]interface{}, key string) string {
|
||||
if val, ok := m[key].(string); ok {
|
||||
return val
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func getIntValue(m map[string]interface{}, key string, defaultValue int) int {
|
||||
if val, ok := m[key].(float64); ok {
|
||||
return int(val)
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// Helper function to log search analytics
|
||||
func logSearchAnalytics(userID uint, query string, filters string, resultsCount int, db *gorm.DB) {
|
||||
analytics := models.SearchAnalytics{
|
||||
UserID: userID,
|
||||
Query: query,
|
||||
Filters: filters,
|
||||
ResultsCount: resultsCount,
|
||||
Took: 0, // Would be measured in actual implementation
|
||||
ContentType: "mixed",
|
||||
}
|
||||
|
||||
db.Create(&analytics)
|
||||
}
|
||||
@@ -1,475 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// BraveSearchResponse represents the response from Brave Search API
|
||||
type BraveSearchResponse struct {
|
||||
Mixed struct {
|
||||
Results []map[string]interface{} `json:"results"`
|
||||
} `json:"mixed"`
|
||||
Web struct {
|
||||
Results []map[string]interface{} `json:"results"`
|
||||
} `json:"web"`
|
||||
Query struct {
|
||||
Original string `json:"original"`
|
||||
Display string `json:"display"`
|
||||
} `json:"query"`
|
||||
}
|
||||
|
||||
type BraveNewsResponse struct {
|
||||
Results []map[string]interface{} `json:"results"`
|
||||
Query struct {
|
||||
Original string `json:"original"`
|
||||
Display string `json:"display"`
|
||||
} `json:"query"`
|
||||
}
|
||||
|
||||
// BraveSearchResult represents a normalized search result returned to the frontend
|
||||
// Note: Brave's API uses fields like "page_age"; we normalize this to "published_date"
|
||||
// to match the BrowserSearch UI expectations.
|
||||
type BraveSearchResult struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Description string `json:"description"`
|
||||
PublishedDate string `json:"published_date,omitempty"`
|
||||
Language string `json:"language,omitempty"`
|
||||
}
|
||||
|
||||
// SearchWeb handles POST /api/v1/search/web
|
||||
func SearchWeb(c *gin.Context) {
|
||||
var req struct {
|
||||
Query string `json:"query" binding:"required"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Set default count if not provided
|
||||
if req.Count == 0 {
|
||||
req.Count = 10
|
||||
}
|
||||
|
||||
// Get user ID from context (authentication is required)
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required for search functionality"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get user's search settings from database
|
||||
searchSettings, err := GetSearchSettingsForAPI(userID.(int))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get search settings"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user has search API key configured
|
||||
if searchSettings.SearchAPIProvider == "brave" {
|
||||
apiKey := searchSettings.BraveAPIKey
|
||||
if apiKey == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Brave Search API key not configured. Please configure your search API key in settings."})
|
||||
return
|
||||
}
|
||||
|
||||
// Build Brave Search API request
|
||||
baseURL := "https://api.search.brave.com/res/v1/web/search"
|
||||
q := url.Values{}
|
||||
q.Set("q", req.Query)
|
||||
q.Set("count", fmt.Sprint(req.Count))
|
||||
endpoint := fmt.Sprintf("%s?%s", baseURL, q.Encode())
|
||||
|
||||
reqHTTP, err := http.NewRequest(http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create Brave request"})
|
||||
return
|
||||
}
|
||||
reqHTTP.Header.Set("Accept", "application/json")
|
||||
reqHTTP.Header.Set("X-Subscription-Token", apiKey)
|
||||
|
||||
resp, err := http.DefaultClient.Do(reqHTTP)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to contact Brave Search API"})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("Brave API error: %d", resp.StatusCode)})
|
||||
return
|
||||
}
|
||||
|
||||
var braveResp BraveSearchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&braveResp); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to decode Brave response"})
|
||||
return
|
||||
}
|
||||
|
||||
// Prefer web.results, fall back to mixed.results
|
||||
resultsRaw := braveResp.Web.Results
|
||||
if len(resultsRaw) == 0 {
|
||||
resultsRaw = braveResp.Mixed.Results
|
||||
}
|
||||
|
||||
results := make([]BraveSearchResult, 0, len(resultsRaw))
|
||||
for _, r := range resultsRaw {
|
||||
title, _ := r["title"].(string)
|
||||
urlStr, _ := r["url"].(string)
|
||||
desc, _ := r["description"].(string)
|
||||
lang, _ := r["language"].(string)
|
||||
pageAge, _ := r["page_age"].(string)
|
||||
|
||||
results = append(results, BraveSearchResult{
|
||||
Title: title,
|
||||
URL: urlStr,
|
||||
Description: desc,
|
||||
PublishedDate: pageAge,
|
||||
Language: lang,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"results": results,
|
||||
"query": gin.H{
|
||||
"original": braveResp.Query.Original,
|
||||
"display": braveResp.Query.Display,
|
||||
},
|
||||
"count": len(results),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Use the configured provider
|
||||
if searchSettings.SearchAPIProvider == "brave" {
|
||||
apiKey := searchSettings.BraveAPIKey
|
||||
if apiKey == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Brave Search API key not configured. Please configure your search API key in settings."})
|
||||
return
|
||||
}
|
||||
|
||||
// Build Brave Search API request
|
||||
baseURL := searchSettings.BraveSearchBaseURL
|
||||
q := url.Values{}
|
||||
q.Set("q", req.Query)
|
||||
q.Set("count", fmt.Sprint(req.Count))
|
||||
endpoint := fmt.Sprintf("%s?%s", baseURL, q.Encode())
|
||||
|
||||
reqHTTP, err := http.NewRequest(http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create Brave request"})
|
||||
return
|
||||
}
|
||||
reqHTTP.Header.Set("Accept", "application/json")
|
||||
reqHTTP.Header.Set("X-Subscription-Token", apiKey)
|
||||
|
||||
resp, err := http.DefaultClient.Do(reqHTTP)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to contact Brave Search API"})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("Brave API error: %d", resp.StatusCode)})
|
||||
return
|
||||
}
|
||||
|
||||
var braveResp BraveSearchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&braveResp); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to decode Brave response"})
|
||||
return
|
||||
}
|
||||
|
||||
// Prefer web.results, fall back to mixed.results
|
||||
resultsRaw := braveResp.Web.Results
|
||||
if len(resultsRaw) == 0 {
|
||||
resultsRaw = braveResp.Mixed.Results
|
||||
}
|
||||
|
||||
results := make([]BraveSearchResult, 0, len(resultsRaw))
|
||||
for _, r := range resultsRaw {
|
||||
title, _ := r["title"].(string)
|
||||
urlStr, _ := r["url"].(string)
|
||||
desc, _ := r["description"].(string)
|
||||
lang, _ := r["language"].(string)
|
||||
pageAge, _ := r["page_age"].(string)
|
||||
|
||||
results = append(results, BraveSearchResult{
|
||||
Title: title,
|
||||
URL: urlStr,
|
||||
Description: desc,
|
||||
PublishedDate: pageAge,
|
||||
Language: lang,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"results": results,
|
||||
"query": gin.H{
|
||||
"original": braveResp.Query.Original,
|
||||
"display": braveResp.Query.Display,
|
||||
},
|
||||
"count": len(results),
|
||||
})
|
||||
} else if searchSettings.SearchAPIProvider == "serper" {
|
||||
// TODO: Implement Serper API integration
|
||||
c.JSON(http.StatusNotImplemented, gin.H{"error": "Serper API integration not yet implemented"})
|
||||
} else {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "No valid search API provider configured. Please configure a search API provider in settings."})
|
||||
}
|
||||
}
|
||||
|
||||
// SearchNews handles POST /api/v1/search/news
|
||||
func SearchNews(c *gin.Context) {
|
||||
var req struct {
|
||||
Query string `json:"query" binding:"required"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Count == 0 {
|
||||
req.Count = 10
|
||||
}
|
||||
|
||||
// Get user ID from context (authentication is required)
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required for search functionality"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get user's search settings from database
|
||||
searchSettings, err := GetSearchSettingsForAPI(userID.(int))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get search settings"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user has search API key configured
|
||||
if searchSettings.SearchAPIProvider == "brave" {
|
||||
apiKey := searchSettings.BraveAPIKey
|
||||
if apiKey == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Brave Search API key not configured. Please configure your search API key in settings."})
|
||||
return
|
||||
}
|
||||
|
||||
baseURL := "https://api.search.brave.com/res/v1/news/search"
|
||||
q := url.Values{}
|
||||
q.Set("q", req.Query)
|
||||
q.Set("count", fmt.Sprint(req.Count))
|
||||
endpoint := fmt.Sprintf("%s?%s", baseURL, q.Encode())
|
||||
|
||||
reqHTTP, err := http.NewRequest(http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create Brave request"})
|
||||
return
|
||||
}
|
||||
reqHTTP.Header.Set("Accept", "application/json")
|
||||
reqHTTP.Header.Set("X-Subscription-Token", apiKey)
|
||||
|
||||
resp, err := http.DefaultClient.Do(reqHTTP)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to contact Brave News API"})
|
||||
return
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
resp.Body.Close()
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("Brave News API error: %d", resp.StatusCode)})
|
||||
return
|
||||
}
|
||||
|
||||
// Read the response body for debugging
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read response body"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
|
||||
var braveResp BraveNewsResponse
|
||||
if err := json.NewDecoder(bytes.NewReader(bodyBytes)).Decode(&braveResp); err != nil {
|
||||
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to decode Brave news response"})
|
||||
return
|
||||
}
|
||||
|
||||
// Debug logging
|
||||
|
||||
|
||||
|
||||
resultsRaw := braveResp.Results
|
||||
results := make([]BraveSearchResult, 0, len(resultsRaw))
|
||||
for _, r := range resultsRaw {
|
||||
title, _ := r["title"].(string)
|
||||
urlStr, _ := r["url"].(string)
|
||||
desc, _ := r["description"].(string)
|
||||
lang, _ := r["language"].(string)
|
||||
pubDate, _ := r["published_date"].(string)
|
||||
if pubDate == "" {
|
||||
pubDate, _ = r["page_age"].(string)
|
||||
}
|
||||
|
||||
results = append(results, BraveSearchResult{
|
||||
Title: title,
|
||||
URL: urlStr,
|
||||
Description: desc,
|
||||
PublishedDate: pubDate,
|
||||
Language: lang,
|
||||
})
|
||||
}
|
||||
|
||||
original := braveResp.Query.Original
|
||||
display := braveResp.Query.Display
|
||||
if original == "" {
|
||||
original = req.Query
|
||||
}
|
||||
if display == "" {
|
||||
display = req.Query
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"results": results,
|
||||
"query": gin.H{
|
||||
"original": original,
|
||||
"display": display,
|
||||
},
|
||||
"count": len(results),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Use the configured provider
|
||||
if searchSettings.SearchAPIProvider == "brave" {
|
||||
apiKey := searchSettings.BraveAPIKey
|
||||
if apiKey == "" {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Brave API key not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
baseURL := "https://api.search.brave.com/res/v1/news/search"
|
||||
q := url.Values{}
|
||||
q.Set("q", req.Query)
|
||||
q.Set("count", fmt.Sprint(req.Count))
|
||||
endpoint := fmt.Sprintf("%s?%s", baseURL, q.Encode())
|
||||
|
||||
reqHTTP, err := http.NewRequest(http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create Brave request"})
|
||||
return
|
||||
}
|
||||
reqHTTP.Header.Set("Accept", "application/json")
|
||||
reqHTTP.Header.Set("X-Subscription-Token", apiKey)
|
||||
|
||||
resp, err := http.DefaultClient.Do(reqHTTP)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to contact Brave News API"})
|
||||
return
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
resp.Body.Close()
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("Brave News API error: %d", resp.StatusCode)})
|
||||
return
|
||||
}
|
||||
|
||||
// Read the response body for debugging
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read response body"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
|
||||
var braveResp BraveNewsResponse
|
||||
if err := json.NewDecoder(bytes.NewReader(bodyBytes)).Decode(&braveResp); err != nil {
|
||||
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to decode Brave news response"})
|
||||
return
|
||||
}
|
||||
|
||||
// Debug logging
|
||||
|
||||
|
||||
|
||||
resultsRaw := braveResp.Results
|
||||
results := make([]BraveSearchResult, 0, len(resultsRaw))
|
||||
for _, r := range resultsRaw {
|
||||
title, _ := r["title"].(string)
|
||||
urlStr, _ := r["url"].(string)
|
||||
desc, _ := r["description"].(string)
|
||||
lang, _ := r["language"].(string)
|
||||
pubDate, _ := r["published_date"].(string)
|
||||
if pubDate == "" {
|
||||
pubDate, _ = r["page_age"].(string)
|
||||
}
|
||||
|
||||
results = append(results, BraveSearchResult{
|
||||
Title: title,
|
||||
URL: urlStr,
|
||||
Description: desc,
|
||||
PublishedDate: pubDate,
|
||||
Language: lang,
|
||||
})
|
||||
}
|
||||
|
||||
original := braveResp.Query.Original
|
||||
display := braveResp.Query.Display
|
||||
if original == "" {
|
||||
original = req.Query
|
||||
}
|
||||
if display == "" {
|
||||
display = req.Query
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"results": results,
|
||||
"query": gin.H{
|
||||
"original": original,
|
||||
"display": display,
|
||||
},
|
||||
"count": len(results),
|
||||
})
|
||||
} else if searchSettings.SearchAPIProvider == "serper" {
|
||||
// TODO: Implement Serper API integration for news
|
||||
c.JSON(http.StatusNotImplemented, gin.H{"error": "Serper API integration not yet implemented"})
|
||||
} else {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "No valid search API provider configured. Please configure a search API provider in settings."})
|
||||
}
|
||||
}
|
||||
|
||||
// GetSearchSuggestions handles GET /api/v1/search/suggestions
|
||||
func GetSearchSuggestions(c *gin.Context) {
|
||||
query := c.Query("q")
|
||||
if query == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Query parameter 'q' is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// For now, return empty suggestions
|
||||
// In a real implementation, you might want to implement autocomplete
|
||||
// using Brave's autocomplete API or your own suggestion engine
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"suggestions": []string{},
|
||||
"query": query,
|
||||
})
|
||||
}
|
||||
@@ -1,523 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/trackeep/backend/config"
|
||||
"github.com/trackeep/backend/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// SearchFilters represents the search filters
|
||||
type SearchFilters struct {
|
||||
Query string `json:"query" binding:"required"`
|
||||
ContentType string `json:"content_type"` // 'all' | 'bookmarks' | 'tasks' | 'notes' | 'files'
|
||||
Tags []string `json:"tags"`
|
||||
DateRange DateRange `json:"date_range"`
|
||||
Author string `json:"author"`
|
||||
Language string `json:"language"`
|
||||
FileTypes []string `json:"file_types"`
|
||||
IsFavorite *bool `json:"is_favorite"`
|
||||
IsRead *bool `json:"is_read"`
|
||||
IsPublic *bool `json:"is_public"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
}
|
||||
|
||||
type DateRange struct {
|
||||
Start time.Time `json:"start"`
|
||||
End time.Time `json:"end"`
|
||||
}
|
||||
|
||||
// SearchResult represents a unified search result
|
||||
type SearchResult struct {
|
||||
ID uint `json:"id"`
|
||||
Type string `json:"type"` // 'bookmark', 'task', 'note', 'file'
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Content string `json:"content"`
|
||||
Tags []models.Tag `json:"tags"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Priority string `json:"priority,omitempty"`
|
||||
DueDate *time.Time `json:"due_date,omitempty"`
|
||||
IsFavorite bool `json:"is_favorite,omitempty"`
|
||||
IsRead bool `json:"is_read,omitempty"`
|
||||
IsPublic bool `json:"is_public,omitempty"`
|
||||
Author string `json:"author,omitempty"`
|
||||
FileSize int64 `json:"file_size,omitempty"`
|
||||
MimeType string `json:"mime_type,omitempty"`
|
||||
FileType string `json:"file_type,omitempty"`
|
||||
Progress int `json:"progress,omitempty"`
|
||||
Highights map[string][]string `json:"highlights,omitempty"` // Search highlights
|
||||
Score float64 `json:"score"` // Relevance score
|
||||
}
|
||||
|
||||
// SearchResponse represents the search response
|
||||
type SearchResponse struct {
|
||||
Results []SearchResult `json:"results"`
|
||||
Total int64 `json:"total"`
|
||||
Query string `json:"query"`
|
||||
Filters SearchFilters `json:"filters"`
|
||||
Took int64 `json:"took"` // Time taken in milliseconds
|
||||
Suggestions []string `json:"suggestions"` // Search suggestions
|
||||
Aggregations map[string]int `json:"aggregations"` // Content type counts
|
||||
}
|
||||
|
||||
// EnhancedSearch handles POST /api/v1/search/enhanced
|
||||
func EnhancedSearch(c *gin.Context) {
|
||||
var filters SearchFilters
|
||||
if err := c.ShouldBindJSON(&filters); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Set defaults
|
||||
if filters.ContentType == "" {
|
||||
filters.ContentType = "all"
|
||||
}
|
||||
if filters.Limit == 0 {
|
||||
filters.Limit = 20
|
||||
}
|
||||
if filters.Limit > 100 {
|
||||
filters.Limit = 100
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
db := config.GetDB()
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
var results []SearchResult
|
||||
var total int64
|
||||
aggregations := make(map[string]int)
|
||||
|
||||
// Search based on content type
|
||||
switch filters.ContentType {
|
||||
case "bookmarks":
|
||||
results, total = searchBookmarks(db, userID, filters)
|
||||
aggregations["bookmarks"] = int(total)
|
||||
case "tasks":
|
||||
results, total = searchTasks(db, userID, filters)
|
||||
aggregations["tasks"] = int(total)
|
||||
case "notes":
|
||||
results, total = searchNotes(db, userID, filters)
|
||||
aggregations["notes"] = int(total)
|
||||
case "files":
|
||||
results, total = searchFiles(db, userID, filters)
|
||||
aggregations["files"] = int(total)
|
||||
default: // all
|
||||
bookmarkResults, bookmarkTotal := searchBookmarks(db, userID, filters)
|
||||
taskResults, taskTotal := searchTasks(db, userID, filters)
|
||||
noteResults, noteTotal := searchNotes(db, userID, filters)
|
||||
fileResults, fileTotal := searchFiles(db, userID, filters)
|
||||
|
||||
results = append(append(append(bookmarkResults, taskResults...), noteResults...), fileResults...)
|
||||
total = bookmarkTotal + taskTotal + noteTotal + fileTotal
|
||||
|
||||
aggregations["bookmarks"] = int(bookmarkTotal)
|
||||
aggregations["tasks"] = int(taskTotal)
|
||||
aggregations["notes"] = int(noteTotal)
|
||||
aggregations["files"] = int(fileTotal)
|
||||
}
|
||||
|
||||
// Apply pagination
|
||||
if filters.Offset > 0 && len(results) > filters.Offset {
|
||||
results = results[filters.Offset:]
|
||||
}
|
||||
if len(results) > filters.Limit {
|
||||
results = results[:filters.Limit]
|
||||
}
|
||||
|
||||
// Get search suggestions
|
||||
suggestions := getSearchSuggestions(db, userID, filters.Query)
|
||||
|
||||
// Calculate time taken
|
||||
took := time.Since(startTime).Milliseconds()
|
||||
|
||||
response := SearchResponse{
|
||||
Results: results,
|
||||
Total: total,
|
||||
Query: filters.Query,
|
||||
Filters: filters,
|
||||
Took: took,
|
||||
Suggestions: suggestions,
|
||||
Aggregations: aggregations,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// searchBookmarks searches bookmarks with filters
|
||||
func searchBookmarks(db *gorm.DB, userID uint, filters SearchFilters) ([]SearchResult, int64) {
|
||||
var bookmarks []models.Bookmark
|
||||
var results []SearchResult
|
||||
|
||||
query := db.Where("user_id = ?", userID)
|
||||
|
||||
// Text search
|
||||
if filters.Query != "" {
|
||||
searchTerm := "%" + strings.ToLower(filters.Query) + "%"
|
||||
query = query.Where("LOWER(title) LIKE ? OR LOWER(description) LIKE ? OR LOWER(content) LIKE ? OR LOWER(url) LIKE ?",
|
||||
searchTerm, searchTerm, searchTerm, searchTerm)
|
||||
}
|
||||
|
||||
// Tags filter
|
||||
if len(filters.Tags) > 0 {
|
||||
query = query.Joins("JOIN bookmark_tags ON bookmarks.id = bookmark_tags.bookmark_id").
|
||||
Joins("JOIN tags ON bookmark_tags.tag_id = tags.id").
|
||||
Where("tags.name IN ?", filters.Tags)
|
||||
}
|
||||
|
||||
// Date range filter
|
||||
if !filters.DateRange.Start.IsZero() {
|
||||
query = query.Where("created_at >= ?", filters.DateRange.Start)
|
||||
}
|
||||
if !filters.DateRange.End.IsZero() {
|
||||
query = query.Where("created_at <= ?", filters.DateRange.End)
|
||||
}
|
||||
|
||||
// Boolean filters
|
||||
if filters.IsFavorite != nil {
|
||||
query = query.Where("is_favorite = ?", *filters.IsFavorite)
|
||||
}
|
||||
if filters.IsRead != nil {
|
||||
query = query.Where("is_read = ?", *filters.IsRead)
|
||||
}
|
||||
|
||||
// Author filter
|
||||
if filters.Author != "" {
|
||||
query = query.Where("LOWER(author) LIKE ?", "%"+strings.ToLower(filters.Author)+"%")
|
||||
}
|
||||
|
||||
// Count total
|
||||
var total int64
|
||||
query.Model(&models.Bookmark{}).Count(&total)
|
||||
|
||||
// Get results with tags
|
||||
if err := query.Preload("Tags").Find(&bookmarks).Error; err != nil {
|
||||
return results, 0
|
||||
}
|
||||
|
||||
// Convert to search results
|
||||
for _, bookmark := range bookmarks {
|
||||
result := SearchResult{
|
||||
ID: bookmark.ID,
|
||||
Type: "bookmark",
|
||||
Title: bookmark.Title,
|
||||
Description: bookmark.Description,
|
||||
Content: bookmark.Content,
|
||||
Tags: bookmark.Tags,
|
||||
CreatedAt: bookmark.CreatedAt,
|
||||
UpdatedAt: bookmark.UpdatedAt,
|
||||
URL: bookmark.URL,
|
||||
IsFavorite: bookmark.IsFavorite,
|
||||
IsRead: bookmark.IsRead,
|
||||
Author: bookmark.Author,
|
||||
Score: calculateRelevanceScore(filters.Query, bookmark.Title, bookmark.Description, bookmark.Content),
|
||||
}
|
||||
|
||||
if bookmark.PublishedAt != nil {
|
||||
result.DueDate = bookmark.PublishedAt // Using DueDate field for published date
|
||||
}
|
||||
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
return results, total
|
||||
}
|
||||
|
||||
// searchTasks searches tasks with filters
|
||||
func searchTasks(db *gorm.DB, userID uint, filters SearchFilters) ([]SearchResult, int64) {
|
||||
var tasks []models.Task
|
||||
var results []SearchResult
|
||||
|
||||
query := db.Where("user_id = ?", userID)
|
||||
|
||||
// Text search
|
||||
if filters.Query != "" {
|
||||
searchTerm := "%" + strings.ToLower(filters.Query) + "%"
|
||||
query = query.Where("LOWER(title) LIKE ? OR LOWER(description) LIKE ?", searchTerm, searchTerm)
|
||||
}
|
||||
|
||||
// Tags filter
|
||||
if len(filters.Tags) > 0 {
|
||||
query = query.Joins("JOIN task_tags ON tasks.id = task_tags.task_id").
|
||||
Joins("JOIN tags ON task_tags.tag_id = tags.id").
|
||||
Where("tags.name IN ?", filters.Tags)
|
||||
}
|
||||
|
||||
// Date range filter
|
||||
if !filters.DateRange.Start.IsZero() {
|
||||
query = query.Where("created_at >= ?", filters.DateRange.Start)
|
||||
}
|
||||
if !filters.DateRange.End.IsZero() {
|
||||
query = query.Where("created_at <= ?", filters.DateRange.End)
|
||||
}
|
||||
|
||||
// Count total
|
||||
var total int64
|
||||
query.Model(&models.Task{}).Count(&total)
|
||||
|
||||
// Get results with tags
|
||||
if err := query.Preload("Tags").Find(&tasks).Error; err != nil {
|
||||
return results, 0
|
||||
}
|
||||
|
||||
// Convert to search results
|
||||
for _, task := range tasks {
|
||||
result := SearchResult{
|
||||
ID: task.ID,
|
||||
Type: "task",
|
||||
Title: task.Title,
|
||||
Description: task.Description,
|
||||
Tags: task.Tags,
|
||||
CreatedAt: task.CreatedAt,
|
||||
UpdatedAt: task.UpdatedAt,
|
||||
Status: string(task.Status),
|
||||
Priority: string(task.Priority),
|
||||
DueDate: task.DueDate,
|
||||
Progress: task.Progress,
|
||||
Score: calculateRelevanceScore(filters.Query, task.Title, task.Description, ""),
|
||||
}
|
||||
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
return results, total
|
||||
}
|
||||
|
||||
// searchNotes searches notes with filters
|
||||
func searchNotes(db *gorm.DB, userID uint, filters SearchFilters) ([]SearchResult, int64) {
|
||||
var notes []models.Note
|
||||
var results []SearchResult
|
||||
|
||||
query := db.Where("user_id = ?", userID)
|
||||
|
||||
// Text search
|
||||
if filters.Query != "" {
|
||||
searchTerm := "%" + strings.ToLower(filters.Query) + "%"
|
||||
query = query.Where("LOWER(title) LIKE ? OR LOWER(description) LIKE ? OR LOWER(content) LIKE ?",
|
||||
searchTerm, searchTerm, searchTerm)
|
||||
}
|
||||
|
||||
// Tags filter
|
||||
if len(filters.Tags) > 0 {
|
||||
query = query.Joins("JOIN note_tags ON notes.id = note_tags.note_id").
|
||||
Joins("JOIN tags ON note_tags.tag_id = tags.id").
|
||||
Where("tags.name IN ?", filters.Tags)
|
||||
}
|
||||
|
||||
// Date range filter
|
||||
if !filters.DateRange.Start.IsZero() {
|
||||
query = query.Where("created_at >= ?", filters.DateRange.Start)
|
||||
}
|
||||
if !filters.DateRange.End.IsZero() {
|
||||
query = query.Where("created_at <= ?", filters.DateRange.End)
|
||||
}
|
||||
|
||||
// Boolean filters
|
||||
if filters.IsPublic != nil {
|
||||
query = query.Where("is_public = ?", *filters.IsPublic)
|
||||
}
|
||||
|
||||
// Count total
|
||||
var total int64
|
||||
query.Model(&models.Note{}).Count(&total)
|
||||
|
||||
// Get results with tags
|
||||
if err := query.Preload("Tags").Find(¬es).Error; err != nil {
|
||||
return results, 0
|
||||
}
|
||||
|
||||
// Convert to search results
|
||||
for _, note := range notes {
|
||||
result := SearchResult{
|
||||
ID: note.ID,
|
||||
Type: "note",
|
||||
Title: note.Title,
|
||||
Description: note.Description,
|
||||
Content: note.Content,
|
||||
Tags: note.Tags,
|
||||
CreatedAt: note.CreatedAt,
|
||||
UpdatedAt: note.UpdatedAt,
|
||||
IsPublic: note.IsPublic,
|
||||
Score: calculateRelevanceScore(filters.Query, note.Title, note.Description, note.Content),
|
||||
}
|
||||
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
return results, total
|
||||
}
|
||||
|
||||
// searchFiles searches files with filters
|
||||
func searchFiles(db *gorm.DB, userID uint, filters SearchFilters) ([]SearchResult, int64) {
|
||||
var files []models.File
|
||||
var results []SearchResult
|
||||
|
||||
query := db.Where("user_id = ?", userID)
|
||||
|
||||
// Text search
|
||||
if filters.Query != "" {
|
||||
searchTerm := "%" + strings.ToLower(filters.Query) + "%"
|
||||
query = query.Where("LOWER(original_name) LIKE ? OR LOWER(description) LIKE ? OR LOWER(content) LIKE ?",
|
||||
searchTerm, searchTerm, searchTerm)
|
||||
}
|
||||
|
||||
// Tags filter
|
||||
if len(filters.Tags) > 0 {
|
||||
query = query.Joins("JOIN file_tags ON files.id = file_tags.file_id").
|
||||
Joins("JOIN tags ON file_tags.tag_id = tags.id").
|
||||
Where("tags.name IN ?", filters.Tags)
|
||||
}
|
||||
|
||||
// Date range filter
|
||||
if !filters.DateRange.Start.IsZero() {
|
||||
query = query.Where("created_at >= ?", filters.DateRange.Start)
|
||||
}
|
||||
if !filters.DateRange.End.IsZero() {
|
||||
query = query.Where("created_at <= ?", filters.DateRange.End)
|
||||
}
|
||||
|
||||
// File type filter
|
||||
if len(filters.FileTypes) > 0 {
|
||||
query = query.Where("file_type IN ?", filters.FileTypes)
|
||||
}
|
||||
|
||||
// Boolean filters
|
||||
if filters.IsPublic != nil {
|
||||
query = query.Where("is_public = ?", *filters.IsPublic)
|
||||
}
|
||||
|
||||
// Count total
|
||||
var total int64
|
||||
query.Model(&models.File{}).Count(&total)
|
||||
|
||||
// Get results with tags
|
||||
if err := query.Preload("Tags").Find(&files).Error; err != nil {
|
||||
return results, 0
|
||||
}
|
||||
|
||||
// Convert to search results
|
||||
for _, file := range files {
|
||||
result := SearchResult{
|
||||
ID: file.ID,
|
||||
Type: "file",
|
||||
Title: file.OriginalName,
|
||||
Description: file.Description,
|
||||
Content: file.Content,
|
||||
Tags: file.Tags,
|
||||
CreatedAt: file.CreatedAt,
|
||||
UpdatedAt: file.UpdatedAt,
|
||||
FileSize: file.FileSize,
|
||||
MimeType: file.MimeType,
|
||||
FileType: string(file.FileType),
|
||||
IsPublic: file.IsPublic,
|
||||
Score: calculateRelevanceScore(filters.Query, file.OriginalName, file.Description, file.Content),
|
||||
}
|
||||
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
return results, total
|
||||
}
|
||||
|
||||
// calculateRelevanceScore calculates a simple relevance score for search results
|
||||
func calculateRelevanceScore(query, title, description, content string) float64 {
|
||||
if query == "" {
|
||||
return 1.0
|
||||
}
|
||||
|
||||
queryLower := strings.ToLower(query)
|
||||
titleLower := strings.ToLower(title)
|
||||
descLower := strings.ToLower(description)
|
||||
contentLower := strings.ToLower(content)
|
||||
|
||||
score := 0.0
|
||||
|
||||
// Title matches are most important
|
||||
if strings.Contains(titleLower, queryLower) {
|
||||
score += 10.0
|
||||
if strings.HasPrefix(titleLower, queryLower) {
|
||||
score += 5.0 // Bonus for prefix match
|
||||
}
|
||||
}
|
||||
|
||||
// Description matches
|
||||
if strings.Contains(descLower, queryLower) {
|
||||
score += 5.0
|
||||
}
|
||||
|
||||
// Content matches
|
||||
if strings.Contains(contentLower, queryLower) {
|
||||
score += 2.0
|
||||
}
|
||||
|
||||
// Word-based scoring
|
||||
queryWords := strings.Fields(queryLower)
|
||||
for _, word := range queryWords {
|
||||
if strings.Contains(titleLower, word) {
|
||||
score += 3.0
|
||||
}
|
||||
if strings.Contains(descLower, word) {
|
||||
score += 1.5
|
||||
}
|
||||
if strings.Contains(contentLower, word) {
|
||||
score += 1.0
|
||||
}
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
// getSearchSuggestions gets search suggestions based on user's search history and popular content
|
||||
func getSearchSuggestions(db *gorm.DB, userID uint, query string) []string {
|
||||
// For now, return empty suggestions
|
||||
// In a future implementation, this could:
|
||||
// - Look at user's search history
|
||||
// - Suggest popular tags
|
||||
// - Suggest based on content titles
|
||||
// - Use AI to generate semantic suggestions
|
||||
return []string{}
|
||||
}
|
||||
|
||||
// SaveSearch handles POST /api/v1/search/save
|
||||
func SaveSearch(c *gin.Context) {
|
||||
var req struct {
|
||||
Query string `json:"query" binding:"required"`
|
||||
Filters SearchFilters `json:"filters"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Alert bool `json:"alert"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Implement saved searches functionality
|
||||
// This would require a SavedSearch model
|
||||
|
||||
c.JSON(http.StatusNotImplemented, gin.H{
|
||||
"message": "Saved searches functionality coming soon",
|
||||
})
|
||||
}
|
||||
|
||||
// GetSearchAnalytics handles GET /api/v1/search/analytics
|
||||
func GetSearchAnalytics(c *gin.Context) {
|
||||
// TODO: Implement search analytics
|
||||
// This could include:
|
||||
// - Most searched terms
|
||||
// - Search frequency over time
|
||||
// - Content type distribution
|
||||
// - Popular filters
|
||||
|
||||
c.JSON(http.StatusNotImplemented, gin.H{
|
||||
"message": "Search analytics functionality coming soon",
|
||||
})
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/trackeep/backend/models"
|
||||
)
|
||||
|
||||
// SearchSettings represents search API configuration
|
||||
type SearchSettings struct {
|
||||
BraveAPIKey string `json:"brave_api_key"`
|
||||
BraveSearchBaseURL string `json:"brave_search_base_url"`
|
||||
SerperAPIKey string `json:"serper_api_key"`
|
||||
SerperBaseURL string `json:"serper_base_url"`
|
||||
SearchAPIProvider string `json:"search_api_provider"`
|
||||
SearchResultsLimit int `json:"search_results_limit"`
|
||||
SearchCacheTTL int `json:"search_cache_ttl"`
|
||||
SearchRateLimit int `json:"search_rate_limit"`
|
||||
}
|
||||
|
||||
// GetSearchSettings handles GET /api/v1/auth/search/settings
|
||||
func GetSearchSettings(c *gin.Context) {
|
||||
userID := c.GetInt("user_id")
|
||||
|
||||
// Get settings from database
|
||||
settings, err := models.GetUserSearchSettings(uint(userID))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get settings"})
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to response format
|
||||
response := SearchSettings{
|
||||
BraveSearchBaseURL: settings.BraveSearchBaseURL,
|
||||
SerperBaseURL: settings.SerperBaseURL,
|
||||
SearchAPIProvider: settings.SearchAPIProvider,
|
||||
SearchResultsLimit: settings.SearchResultsLimit,
|
||||
SearchCacheTTL: settings.SearchCacheTTL,
|
||||
SearchRateLimit: settings.SearchRateLimit,
|
||||
}
|
||||
|
||||
// Mask API keys for security
|
||||
if settings.BraveAPIKey != "" && len(settings.BraveAPIKey) > 8 {
|
||||
response.BraveAPIKey = settings.BraveAPIKey[:4] + "********" + settings.BraveAPIKey[len(settings.BraveAPIKey)-4:]
|
||||
}
|
||||
if settings.SerperAPIKey != "" && len(settings.SerperAPIKey) > 8 {
|
||||
response.SerperAPIKey = settings.SerperAPIKey[:4] + "********" + settings.SerperAPIKey[len(settings.SerperAPIKey)-4:]
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// UpdateSearchSettings handles PUT /api/v1/auth/search/settings
|
||||
func UpdateSearchSettings(c *gin.Context) {
|
||||
userID := c.GetInt("user_id")
|
||||
|
||||
var newSettings SearchSettings
|
||||
if err := c.ShouldBindJSON(&newSettings); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Get existing settings to preserve API keys if they're masked
|
||||
existingSettings, err := models.GetUserSearchSettings(uint(userID))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get existing settings"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if API keys are masked and preserve existing values
|
||||
if len(newSettings.BraveAPIKey) > 8 && newSettings.BraveAPIKey[4:12] == "********" {
|
||||
newSettings.BraveAPIKey = existingSettings.BraveAPIKey
|
||||
}
|
||||
if len(newSettings.SerperAPIKey) > 8 && newSettings.SerperAPIKey[4:12] == "********" {
|
||||
newSettings.SerperAPIKey = existingSettings.SerperAPIKey
|
||||
}
|
||||
|
||||
// Update model
|
||||
updatedSettings := &models.UserSearchSettings{
|
||||
BraveAPIKey: newSettings.BraveAPIKey,
|
||||
BraveSearchBaseURL: newSettings.BraveSearchBaseURL,
|
||||
SerperAPIKey: newSettings.SerperAPIKey,
|
||||
SerperBaseURL: newSettings.SerperBaseURL,
|
||||
SearchAPIProvider: newSettings.SearchAPIProvider,
|
||||
SearchResultsLimit: newSettings.SearchResultsLimit,
|
||||
SearchCacheTTL: newSettings.SearchCacheTTL,
|
||||
SearchRateLimit: newSettings.SearchRateLimit,
|
||||
}
|
||||
|
||||
// Save to database
|
||||
err = models.SaveUserSearchSettings(uint(userID), updatedSettings)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save settings"})
|
||||
return
|
||||
}
|
||||
|
||||
// Return masked settings for consistency
|
||||
GetSearchSettings(c)
|
||||
}
|
||||
|
||||
// GetTestSearchSettings handles GET /api/v1/test-search-settings (for demo mode)
|
||||
func GetTestSearchSettings(c *gin.Context) {
|
||||
settings := getDefaultSearchSettings()
|
||||
|
||||
// Mask API keys for security
|
||||
if settings.BraveAPIKey != "" && len(settings.BraveAPIKey) > 8 {
|
||||
settings.BraveAPIKey = settings.BraveAPIKey[:4] + "********" + settings.BraveAPIKey[len(settings.BraveAPIKey)-4:]
|
||||
}
|
||||
if settings.SerperAPIKey != "" && len(settings.SerperAPIKey) > 8 {
|
||||
settings.SerperAPIKey = settings.SerperAPIKey[:4] + "********" + settings.SerperAPIKey[len(settings.SerperAPIKey)-4:]
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, settings)
|
||||
}
|
||||
|
||||
// GetSearchSettingsForAPI returns unmasked search settings for internal API use
|
||||
func GetSearchSettingsForAPI(userID int) (SearchSettings, error) {
|
||||
settings, err := models.GetUserSearchSettings(uint(userID))
|
||||
if err != nil {
|
||||
// Return default settings if error
|
||||
defaultSettings := getDefaultSearchSettings()
|
||||
return defaultSettings, nil
|
||||
}
|
||||
|
||||
return SearchSettings{
|
||||
BraveAPIKey: settings.BraveAPIKey,
|
||||
BraveSearchBaseURL: settings.BraveSearchBaseURL,
|
||||
SerperAPIKey: settings.SerperAPIKey,
|
||||
SerperBaseURL: settings.SerperBaseURL,
|
||||
SearchAPIProvider: settings.SearchAPIProvider,
|
||||
SearchResultsLimit: settings.SearchResultsLimit,
|
||||
SearchCacheTTL: settings.SearchCacheTTL,
|
||||
SearchRateLimit: settings.SearchRateLimit,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func getDefaultSearchSettings() SearchSettings {
|
||||
return SearchSettings{
|
||||
BraveAPIKey: getEnvWithDefault("BRAVE_API_KEY", "BSAw0HNI1v3rKmXlSTr0C_UfZDjw7fT"),
|
||||
BraveSearchBaseURL: getEnvWithDefault("BRAVE_SEARCH_BASE_URL", "https://api.search.brave.com/res/v1/web/search"),
|
||||
SerperAPIKey: getEnvWithDefault("SERPER_API_KEY", "6f1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2"),
|
||||
SerperBaseURL: getEnvWithDefault("SERPER_BASE_URL", "https://google.serper.dev/search"),
|
||||
SearchAPIProvider: getEnvWithDefault("SEARCH_API_PROVIDER", "brave"),
|
||||
SearchResultsLimit: getIntEnvWithDefault("SEARCH_RESULTS_LIMIT", 10),
|
||||
SearchCacheTTL: getIntEnvWithDefault("SEARCH_CACHE_TTL", 300),
|
||||
SearchRateLimit: getIntEnvWithDefault("SEARCH_RATE_LIMIT", 100),
|
||||
}
|
||||
}
|
||||
|
||||
func getEnvWithDefault(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func getIntEnvWithDefault(key string, defaultValue int) int {
|
||||
value := os.Getenv(key)
|
||||
if value == "" {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
if intValue, err := strconv.Atoi(value); err == nil {
|
||||
return intValue
|
||||
}
|
||||
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func getBoolEnvWithDefault(key string, defaultValue bool) bool {
|
||||
value := os.Getenv(key)
|
||||
if value == "" {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
if value == "true" || value == "1" {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -1,603 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/trackeep/backend/config"
|
||||
"github.com/trackeep/backend/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// SemanticSearchRequest represents a semantic search request
|
||||
type SemanticSearchRequest struct {
|
||||
Query string `json:"query" binding:"required"`
|
||||
ContentType string `json:"content_type"` // all | bookmarks | tasks | notes | files | calendar_events | youtube_videos | learning_paths | chat_messages
|
||||
Limit int `json:"limit"`
|
||||
Threshold float64 `json:"threshold"` // Similarity threshold (0-1)
|
||||
}
|
||||
|
||||
// SemanticSearchResponse represents semantic search response
|
||||
type SemanticSearchResponse struct {
|
||||
Results []SemanticSearchResult `json:"results"`
|
||||
Query string `json:"query"`
|
||||
Took int64 `json:"took"`
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
// SemanticSearchResult represents a semantic search result
|
||||
type SemanticSearchResult struct {
|
||||
ID uint `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Content string `json:"content"`
|
||||
Similarity float64 `json:"similarity"`
|
||||
Highlights []string `json:"highlights"`
|
||||
Tags []models.Tag `json:"tags,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Priority string `json:"priority,omitempty"`
|
||||
}
|
||||
|
||||
// GenerateEmbeddingRequest represents request to generate embeddings
|
||||
type GenerateEmbeddingRequest struct {
|
||||
Text string `json:"text" binding:"required"`
|
||||
ContentType string `json:"content_type"`
|
||||
ContentID uint `json:"content_id"`
|
||||
}
|
||||
|
||||
// GenerateEmbeddingResponse represents embedding generation response
|
||||
type GenerateEmbeddingResponse struct {
|
||||
Embedding []float64 `json:"embedding"`
|
||||
Model string `json:"model"`
|
||||
Dimensions int `json:"dimensions"`
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// SemanticSearch handles POST /api/v1/search/semantic
|
||||
func SemanticSearch(c *gin.Context) {
|
||||
var req SemanticSearchRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Set defaults
|
||||
if req.Limit == 0 {
|
||||
req.Limit = 20
|
||||
}
|
||||
if req.Threshold == 0 {
|
||||
req.Threshold = 0.7 // Default similarity threshold
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
db := config.GetDB()
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
// Generate embedding for the search query
|
||||
queryEmbedding, err := generateEmbedding(req.Query)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Failed to generate query embedding",
|
||||
"details": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Search for similar content
|
||||
results, err := findSimilarContent(db, userID, queryEmbedding, req.ContentType, req.Limit, req.Threshold)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Failed to search similar content",
|
||||
"details": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
took := time.Since(startTime).Milliseconds()
|
||||
|
||||
response := SemanticSearchResponse{
|
||||
Results: results,
|
||||
Query: req.Query,
|
||||
Took: took,
|
||||
Model: "text-embedding-ada-002",
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// GenerateEmbedding handles POST /api/v1/search/embeddings/generate
|
||||
func GenerateEmbedding(c *gin.Context) {
|
||||
var req GenerateEmbeddingRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate embedding
|
||||
embedding, err := generateEmbedding(req.Text)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Failed to generate embedding",
|
||||
"details": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Store embedding if content reference is provided
|
||||
if req.ContentType != "" && req.ContentID > 0 {
|
||||
db := config.GetDB()
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
embeddingJSON, _ := json.Marshal(embedding)
|
||||
|
||||
contentEmbedding := models.ContentEmbedding{
|
||||
ContentType: req.ContentType,
|
||||
ContentID: req.ContentID,
|
||||
Embedding: string(embeddingJSON),
|
||||
Model: "text-embedding-ada-002",
|
||||
Dimensions: len(embedding),
|
||||
TextContent: req.Text,
|
||||
UserID: userID,
|
||||
}
|
||||
|
||||
if err := db.Create(&contentEmbedding).Error; err != nil {
|
||||
// Log error but don't fail the request
|
||||
log.Printf("Failed to store embedding: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
response := GenerateEmbeddingResponse{
|
||||
Embedding: embedding,
|
||||
Model: "text-embedding-ada-002",
|
||||
Dimensions: len(embedding),
|
||||
Success: true,
|
||||
Message: "Embedding generated successfully",
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// ReindexContent handles POST /api/v1/search/reindex
|
||||
func ReindexContent(c *gin.Context) {
|
||||
db := config.GetDB()
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
// Start background job to reindex all content
|
||||
go func() {
|
||||
reindexUserContent(db, userID)
|
||||
}()
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Content reindexing started in background",
|
||||
"status": "processing",
|
||||
})
|
||||
}
|
||||
|
||||
// generateEmbedding generates embedding for text using OpenAI API (mock implementation)
|
||||
func generateEmbedding(text string) ([]float64, error) {
|
||||
// TODO: Replace with actual OpenAI API call
|
||||
// For now, return a mock embedding for demonstration
|
||||
embedding := make([]float64, 1536) // OpenAI embedding dimensions
|
||||
|
||||
// Generate pseudo-random but deterministic embedding based on text
|
||||
hash := simpleHash(text)
|
||||
for i := range embedding {
|
||||
embedding[i] = math.Sin(float64(hash+i)) * 0.5
|
||||
}
|
||||
|
||||
return embedding, nil
|
||||
}
|
||||
|
||||
// simpleHash creates a simple hash from string
|
||||
func simpleHash(s string) int {
|
||||
hash := 0
|
||||
for _, char := range s {
|
||||
hash = hash*31 + int(char)
|
||||
}
|
||||
return hash
|
||||
}
|
||||
|
||||
// findSimilarContent finds content similar to the given embedding
|
||||
func findSimilarContent(db *gorm.DB, userID uint, queryEmbedding []float64, contentType string, limit int, threshold float64) ([]SemanticSearchResult, error) {
|
||||
var results []SemanticSearchResult
|
||||
|
||||
// Get all embeddings for the user
|
||||
var embeddings []models.ContentEmbedding
|
||||
query := db.Where("user_id = ?", userID)
|
||||
|
||||
if contentType != "all" && contentType != "" {
|
||||
query = query.Where("content_type = ?", normalizeSemanticContentType(contentType))
|
||||
}
|
||||
|
||||
if err := query.Find(&embeddings).Error; err != nil {
|
||||
return results, err
|
||||
}
|
||||
|
||||
// Calculate similarity scores
|
||||
type similarityScore struct {
|
||||
embedding models.ContentEmbedding
|
||||
score float64
|
||||
}
|
||||
|
||||
var scores []similarityScore
|
||||
|
||||
for _, embedding := range embeddings {
|
||||
var storedEmbedding []float64
|
||||
if err := json.Unmarshal([]byte(embedding.Embedding), &storedEmbedding); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
similarity := cosineSimilarity(queryEmbedding, storedEmbedding)
|
||||
if similarity >= threshold {
|
||||
scores = append(scores, similarityScore{
|
||||
embedding: embedding,
|
||||
score: similarity,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by similarity (descending)
|
||||
for i := 0; i < len(scores)-1; i++ {
|
||||
for j := i + 1; j < len(scores); j++ {
|
||||
if scores[i].score < scores[j].score {
|
||||
scores[i], scores[j] = scores[j], scores[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Limit results
|
||||
if len(scores) > limit {
|
||||
scores = scores[:limit]
|
||||
}
|
||||
|
||||
// Fetch actual content and build results
|
||||
for _, score := range scores {
|
||||
result, err := buildSemanticSearchResult(db, score.embedding, score.score)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// cosineSimilarity calculates cosine similarity between two vectors
|
||||
func cosineSimilarity(a, b []float64) float64 {
|
||||
if len(a) != len(b) {
|
||||
return 0
|
||||
}
|
||||
|
||||
var dotProduct, normA, normB float64
|
||||
|
||||
for i := range a {
|
||||
dotProduct += a[i] * b[i]
|
||||
normA += a[i] * a[i]
|
||||
normB += b[i] * b[i]
|
||||
}
|
||||
|
||||
if normA == 0 || normB == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
return dotProduct / (math.Sqrt(normA) * math.Sqrt(normB))
|
||||
}
|
||||
|
||||
// buildSemanticSearchResult builds a search result from embedding and content
|
||||
func buildSemanticSearchResult(db *gorm.DB, embedding models.ContentEmbedding, similarity float64) (SemanticSearchResult, error) {
|
||||
result := SemanticSearchResult{
|
||||
Similarity: similarity,
|
||||
}
|
||||
|
||||
switch embedding.ContentType {
|
||||
case "bookmark":
|
||||
var bookmark models.Bookmark
|
||||
if err := db.Preload("Tags").First(&bookmark, embedding.ContentID).Error; err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
result.ID = bookmark.ID
|
||||
result.Type = "bookmark"
|
||||
result.Title = bookmark.Title
|
||||
result.Description = bookmark.Description
|
||||
result.Content = bookmark.Content
|
||||
result.Tags = bookmark.Tags
|
||||
result.CreatedAt = bookmark.CreatedAt
|
||||
result.UpdatedAt = bookmark.UpdatedAt
|
||||
result.URL = bookmark.URL
|
||||
|
||||
case "task":
|
||||
var task models.Task
|
||||
if err := db.Preload("Tags").First(&task, embedding.ContentID).Error; err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
result.ID = task.ID
|
||||
result.Type = "task"
|
||||
result.Title = task.Title
|
||||
result.Description = task.Description
|
||||
result.Tags = task.Tags
|
||||
result.CreatedAt = task.CreatedAt
|
||||
result.UpdatedAt = task.UpdatedAt
|
||||
result.Status = string(task.Status)
|
||||
result.Priority = string(task.Priority)
|
||||
|
||||
case "note":
|
||||
var note models.Note
|
||||
if err := db.Preload("Tags").First(¬e, embedding.ContentID).Error; err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
result.ID = note.ID
|
||||
result.Type = "note"
|
||||
result.Title = note.Title
|
||||
result.Description = note.Description
|
||||
result.Content = note.Content
|
||||
result.Tags = note.Tags
|
||||
result.CreatedAt = note.CreatedAt
|
||||
result.UpdatedAt = note.UpdatedAt
|
||||
|
||||
case "file":
|
||||
var file models.File
|
||||
if err := db.Preload("Tags").First(&file, embedding.ContentID).Error; err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
result.ID = file.ID
|
||||
result.Type = "file"
|
||||
result.Title = file.OriginalName
|
||||
result.Description = file.Description
|
||||
result.Content = file.Content
|
||||
result.Tags = file.Tags
|
||||
result.CreatedAt = file.CreatedAt
|
||||
result.UpdatedAt = file.UpdatedAt
|
||||
|
||||
case "calendar_event":
|
||||
var event models.CalendarEvent
|
||||
if err := db.First(&event, embedding.ContentID).Error; err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
result.ID = event.ID
|
||||
result.Type = "calendar_event"
|
||||
result.Title = event.Title
|
||||
result.Description = event.Description
|
||||
result.Content = event.Description
|
||||
result.CreatedAt = event.CreatedAt
|
||||
result.UpdatedAt = event.UpdatedAt
|
||||
result.Priority = event.Priority
|
||||
|
||||
case "youtube_video":
|
||||
var video models.VideoBookmark
|
||||
if err := db.First(&video, embedding.ContentID).Error; err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
result.ID = video.ID
|
||||
result.Type = "youtube_video"
|
||||
result.Title = video.Title
|
||||
result.Description = video.Description
|
||||
result.Content = video.Description
|
||||
result.CreatedAt = video.CreatedAt
|
||||
result.UpdatedAt = video.UpdatedAt
|
||||
result.URL = video.URL
|
||||
|
||||
case "learning_path":
|
||||
var path models.LearningPath
|
||||
if err := db.First(&path, embedding.ContentID).Error; err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
result.ID = path.ID
|
||||
result.Type = "learning_path"
|
||||
result.Title = path.Title
|
||||
result.Description = path.Description
|
||||
result.Content = path.Description
|
||||
result.CreatedAt = path.CreatedAt
|
||||
result.UpdatedAt = path.UpdatedAt
|
||||
|
||||
case "chat_message":
|
||||
var message models.Message
|
||||
if err := db.First(&message, embedding.ContentID).Error; err != nil {
|
||||
return result, err
|
||||
}
|
||||
if message.IsSensitive {
|
||||
return result, fmt.Errorf("sensitive message excluded from semantic search")
|
||||
}
|
||||
|
||||
result.ID = message.ID
|
||||
result.Type = "chat_message"
|
||||
result.Title = "Chat message"
|
||||
result.Description = compactSemanticText(message.Body, 140)
|
||||
result.Content = message.Body
|
||||
result.CreatedAt = message.CreatedAt
|
||||
result.UpdatedAt = message.UpdatedAt
|
||||
result.URL = fmt.Sprintf("/app/messages?conversationId=%d&messageId=%d", message.ConversationID, message.ID)
|
||||
}
|
||||
|
||||
// Generate highlights (simplified)
|
||||
result.Highlights = generateHighlights(embedding.TextContent, 3)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// generateHighlights generates text highlights
|
||||
func generateHighlights(text string, count int) []string {
|
||||
if text == "" {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
// Simple highlight generation - split into sentences and return first few
|
||||
sentences := strings.Split(text, ".")
|
||||
if len(sentences) > count {
|
||||
sentences = sentences[:count]
|
||||
}
|
||||
|
||||
var highlights []string
|
||||
for _, sentence := range sentences {
|
||||
sentence = strings.TrimSpace(sentence)
|
||||
if len(sentence) > 10 {
|
||||
highlights = append(highlights, sentence+".")
|
||||
}
|
||||
if len(highlights) >= count {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return highlights
|
||||
}
|
||||
|
||||
// reindexUserContent reindexes all content for a user
|
||||
func reindexUserContent(db *gorm.DB, userID uint) {
|
||||
log.Printf("Starting reindexing for user %d", userID)
|
||||
|
||||
// Reindex bookmarks
|
||||
var bookmarks []models.Bookmark
|
||||
db.Where("user_id = ?", userID).Find(&bookmarks)
|
||||
|
||||
for _, bookmark := range bookmarks {
|
||||
text := bookmark.Title + " " + bookmark.Description + " " + bookmark.Content
|
||||
upsertEmbedding(db, userID, "bookmark", bookmark.ID, text)
|
||||
}
|
||||
|
||||
// Tasks
|
||||
var tasks []models.Task
|
||||
db.Where("user_id = ?", userID).Find(&tasks)
|
||||
for _, task := range tasks {
|
||||
text := task.Title + " " + task.Description
|
||||
upsertEmbedding(db, userID, "task", task.ID, text)
|
||||
}
|
||||
|
||||
// Notes
|
||||
var notes []models.Note
|
||||
db.Where("user_id = ?", userID).Find(¬es)
|
||||
for _, note := range notes {
|
||||
if note.IsEncrypted {
|
||||
continue
|
||||
}
|
||||
text := note.Title + " " + note.Description + " " + note.Content
|
||||
upsertEmbedding(db, userID, "note", note.ID, text)
|
||||
}
|
||||
|
||||
// Files
|
||||
var files []models.File
|
||||
db.Where("user_id = ?", userID).Find(&files)
|
||||
for _, file := range files {
|
||||
text := file.OriginalName + " " + file.Description + " " + file.Content
|
||||
upsertEmbedding(db, userID, "file", file.ID, text)
|
||||
}
|
||||
|
||||
// Calendar events
|
||||
var events []models.CalendarEvent
|
||||
db.Where("user_id = ?", userID).Find(&events)
|
||||
for _, event := range events {
|
||||
text := event.Title + " " + event.Description + " " + event.Type + " " + event.Priority
|
||||
upsertEmbedding(db, userID, "calendar_event", event.ID, text)
|
||||
}
|
||||
|
||||
// YouTube bookmarks
|
||||
var videos []models.VideoBookmark
|
||||
db.Where("user_id = ?", userID).Find(&videos)
|
||||
for _, video := range videos {
|
||||
text := video.Title + " " + video.Description + " " + video.Channel + " " + video.URL
|
||||
upsertEmbedding(db, userID, "youtube_video", video.ID, text)
|
||||
}
|
||||
|
||||
// Learning paths
|
||||
var learningPaths []models.LearningPath
|
||||
db.Where("creator_id = ?", userID).Find(&learningPaths)
|
||||
for _, path := range learningPaths {
|
||||
text := path.Title + " " + path.Description + " " + path.Category + " " + path.Difficulty
|
||||
upsertEmbedding(db, userID, "learning_path", path.ID, text)
|
||||
}
|
||||
|
||||
// Chat messages (skip sensitive/vault content)
|
||||
var messages []models.Message
|
||||
db.Model(&models.Message{}).
|
||||
Joins("JOIN conversation_members cm ON cm.conversation_id = messages.conversation_id").
|
||||
Joins("JOIN conversations ON conversations.id = messages.conversation_id").
|
||||
Where("cm.user_id = ?", userID).
|
||||
Where("conversations.type <> ?", models.ConversationTypePasswordVault).
|
||||
Where("messages.deleted_at IS NULL").
|
||||
Find(&messages)
|
||||
for _, message := range messages {
|
||||
if message.IsSensitive {
|
||||
continue
|
||||
}
|
||||
upsertEmbedding(db, userID, "chat_message", message.ID, message.Body)
|
||||
}
|
||||
|
||||
log.Printf("Reindexing completed for user %d", userID)
|
||||
}
|
||||
|
||||
func upsertEmbedding(db *gorm.DB, userID uint, contentType string, contentID uint, text string) {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
|
||||
embedding, err := generateEmbedding(text)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
embeddingJSON, _ := json.Marshal(embedding)
|
||||
|
||||
contentEmbedding := models.ContentEmbedding{
|
||||
ContentType: contentType,
|
||||
ContentID: contentID,
|
||||
Embedding: string(embeddingJSON),
|
||||
Model: "text-embedding-ada-002",
|
||||
Dimensions: len(embedding),
|
||||
TextContent: text,
|
||||
UserID: userID,
|
||||
}
|
||||
|
||||
db.Where("content_type = ? AND content_id = ? AND user_id = ?", contentType, contentID, userID).Delete(&models.ContentEmbedding{})
|
||||
db.Create(&contentEmbedding)
|
||||
}
|
||||
|
||||
func normalizeSemanticContentType(contentType string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(contentType)) {
|
||||
case "bookmarks":
|
||||
return "bookmark"
|
||||
case "tasks":
|
||||
return "task"
|
||||
case "notes":
|
||||
return "note"
|
||||
case "files":
|
||||
return "file"
|
||||
case "calendar_events":
|
||||
return "calendar_event"
|
||||
case "youtube_videos":
|
||||
return "youtube_video"
|
||||
case "learning_paths":
|
||||
return "learning_path"
|
||||
case "chat_messages":
|
||||
return "chat_message"
|
||||
default:
|
||||
return strings.ToLower(strings.TrimSpace(contentType))
|
||||
}
|
||||
}
|
||||
|
||||
func compactSemanticText(text string, limit int) string {
|
||||
text = strings.TrimSpace(text)
|
||||
if len(text) <= limit {
|
||||
return text
|
||||
}
|
||||
if limit < 4 {
|
||||
return text
|
||||
}
|
||||
return strings.TrimSpace(text[:limit-3]) + "..."
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/trackeep/backend/config"
|
||||
@@ -98,3 +100,19 @@ func getDefaultUpdateSettings() UpdateSettings {
|
||||
PrereleaseUpdates: getBoolEnvWithDefault("PRERELEASE_UPDATES", false),
|
||||
}
|
||||
}
|
||||
|
||||
func getBoolEnvWithDefault(key string, defaultValue bool) bool {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
if parsed, err := strconv.ParseBool(value); err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func getEnvWithDefault(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
+1
-124
@@ -215,7 +215,6 @@ func main() {
|
||||
goalsHabitsHandler := handlers.NewGoalsHabitsHandler(config.GetDB())
|
||||
socialHandler := handlers.NewSocialHandler(config.GetDB())
|
||||
teamsHandler := handlers.NewTeamsHandler(config.GetDB())
|
||||
aiRecommendationHandler := handlers.NewAIRecommendationHandler(config.GetDB())
|
||||
marketplaceHandler := handlers.NewMarketplaceHandler(config.GetDB())
|
||||
communityHandler := handlers.NewCommunityHandler(config.GetDB())
|
||||
performanceHandler := handlers.NewPerformanceHandler(config.GetDB())
|
||||
@@ -256,8 +255,6 @@ func main() {
|
||||
github.GET("/activity", handlers.GetGitHubActivity)
|
||||
}
|
||||
|
||||
v1.POST("/youtube-search-test", handlers.YouTubeSearchTest)
|
||||
|
||||
// Protected auth routes (with demo mode protection)
|
||||
authProtected := v1.Group("/auth")
|
||||
authProtected.Use(handlers.AuthMiddleware())
|
||||
@@ -280,25 +277,13 @@ func main() {
|
||||
authProtected.POST("/decrypt/content", handlers.DecryptNoteContent)
|
||||
authProtected.GET("/encryption/status", handlers.GetEncryptionStatus)
|
||||
|
||||
// AI Settings routes
|
||||
authProtected.GET("/ai/settings", handlers.GetAISettings)
|
||||
authProtected.PUT("/ai/settings", handlers.UpdateAISettings)
|
||||
authProtected.POST("/ai/test-connection", handlers.TestAIConnection)
|
||||
|
||||
// Search Settings routes
|
||||
authProtected.GET("/search/settings", handlers.GetSearchSettings)
|
||||
authProtected.PUT("/search/settings", handlers.UpdateSearchSettings)
|
||||
|
||||
// Update Settings routes
|
||||
authProtected.GET("/update/settings", handlers.GetUpdateSettings)
|
||||
authProtected.PUT("/update/settings", handlers.UpdateUpdateSettings)
|
||||
}
|
||||
|
||||
// Test AI settings without auth
|
||||
v1.GET("/test-ai-settings", handlers.GetAISettings)
|
||||
|
||||
// Test search and update settings without auth (for demo mode)
|
||||
v1.GET("/test-search-settings", handlers.GetTestSearchSettings)
|
||||
// Test update settings without auth (for demo mode)
|
||||
v1.GET("/test-update-settings", handlers.GetTestUpdateSettings)
|
||||
|
||||
// Dashboard routes (protected)
|
||||
@@ -435,46 +420,6 @@ func main() {
|
||||
notes.GET("/:id/encrypted", handlers.GetEncryptedNote)
|
||||
}
|
||||
|
||||
// Chat routes (protected)
|
||||
chat := v1.Group("/chat")
|
||||
chat.Use(handlers.AuthMiddleware())
|
||||
{
|
||||
chat.POST("/send", handlers.SendMessage)
|
||||
chat.GET("/sessions", handlers.GetSessions)
|
||||
chat.GET("/sessions/:id/messages", handlers.GetSessionMessages)
|
||||
chat.DELETE("/sessions/:id", handlers.DeleteSession)
|
||||
}
|
||||
|
||||
// Messaging routes (Discord-like user communication)
|
||||
messages := v1.Group("/messages")
|
||||
messages.Use(handlers.AuthMiddleware())
|
||||
{
|
||||
messages.GET("/conversations", handlers.GetConversations)
|
||||
messages.POST("/conversations", handlers.CreateConversation)
|
||||
messages.GET("/conversations/:id", handlers.GetConversation)
|
||||
messages.PATCH("/conversations/:id", handlers.UpdateConversation)
|
||||
messages.POST("/conversations/:id/members", handlers.AddConversationMember)
|
||||
messages.DELETE("/conversations/:id/members/:userId", handlers.RemoveConversationMember)
|
||||
messages.GET("/conversations/:id/messages", handlers.GetConversationMessages)
|
||||
messages.POST("/conversations/:id/messages", handlers.CreateConversationMessage)
|
||||
messages.PATCH("/messages/:id", handlers.UpdateMessage)
|
||||
messages.DELETE("/messages/:id", handlers.DeleteMessage)
|
||||
messages.POST("/messages/:id/reactions", handlers.AddMessageReaction)
|
||||
messages.DELETE("/messages/:id/reactions/:emoji", handlers.RemoveMessageReaction)
|
||||
messages.POST("/messages/search", handlers.SearchMessages)
|
||||
messages.GET("/messages/:id/suggestions", handlers.GetMessageSuggestions)
|
||||
messages.POST("/messages/:id/suggestions/:suggestionId/accept", handlers.AcceptMessageSuggestion)
|
||||
messages.POST("/messages/:id/suggestions/:suggestionId/dismiss", handlers.DismissMessageSuggestion)
|
||||
messages.POST("/messages/:id/reveal-sensitive", handlers.RevealSensitiveMessage)
|
||||
messages.GET("/ws", handlers.MessagesWebSocket)
|
||||
|
||||
messages.GET("/password-vault/items", handlers.GetPasswordVaultItems)
|
||||
messages.POST("/password-vault/items", handlers.CreatePasswordVaultItem)
|
||||
messages.POST("/password-vault/items/:id/share", handlers.SharePasswordVaultItem)
|
||||
messages.POST("/password-vault/items/:id/reveal", handlers.RevealPasswordVaultItem)
|
||||
messages.POST("/password-vault/items/:id/unshare", handlers.UnsharePasswordVaultItem)
|
||||
}
|
||||
|
||||
// Member routes (protected)
|
||||
members := v1.Group("/members")
|
||||
members.Use(handlers.AuthMiddleware())
|
||||
@@ -515,37 +460,6 @@ func main() {
|
||||
videoBookmarks.POST("/:id/toggle-favorite", videoBookmarkHandler.ToggleFavorite)
|
||||
}
|
||||
|
||||
// Search routes (protected)
|
||||
search := v1.Group("/search")
|
||||
search.Use(handlers.AuthMiddleware())
|
||||
{
|
||||
search.POST("/web", handlers.SearchWeb)
|
||||
search.POST("/news", handlers.SearchNews)
|
||||
search.GET("/suggestions", handlers.GetSearchSuggestions)
|
||||
|
||||
// Enhanced search features
|
||||
search.POST("/enhanced", handlers.EnhancedSearch)
|
||||
search.POST("/save", handlers.SaveSearch)
|
||||
search.GET("/analytics", handlers.GetSearchAnalytics)
|
||||
|
||||
// Saved searches management
|
||||
savedSearches := search.Group("/saved")
|
||||
{
|
||||
savedSearches.POST("", handlers.CreateSavedSearch)
|
||||
savedSearches.GET("", handlers.GetUserSavedSearches)
|
||||
savedSearches.GET("/:id", handlers.GetSavedSearch)
|
||||
savedSearches.PUT("/:id", handlers.UpdateSavedSearch)
|
||||
savedSearches.DELETE("/:id", handlers.DeleteSavedSearch)
|
||||
savedSearches.POST("/:id/run", handlers.RunSavedSearch)
|
||||
savedSearches.GET("/tags", handlers.GetSavedSearchTags)
|
||||
}
|
||||
|
||||
// Semantic search features
|
||||
search.POST("/semantic", handlers.SemanticSearch)
|
||||
search.POST("/embeddings/generate", handlers.GenerateEmbedding)
|
||||
search.POST("/reindex", handlers.ReindexContent)
|
||||
}
|
||||
|
||||
// Time tracking routes (protected)
|
||||
timeEntries := v1.Group("/time-entries")
|
||||
timeEntries.Use(handlers.AuthMiddleware())
|
||||
@@ -575,30 +489,6 @@ func main() {
|
||||
calendar.PUT("/:id/toggle-complete", calendarHandler.ToggleEventCompletion)
|
||||
}
|
||||
|
||||
// AI Features routes (protected)
|
||||
ai := v1.Group("/ai")
|
||||
ai.Use(handlers.AuthMiddleware())
|
||||
{
|
||||
// AI providers
|
||||
ai.GET("/providers", handlers.GetAIProviders)
|
||||
|
||||
// Content summarization
|
||||
ai.POST("/summarize", handlers.SummarizeContent)
|
||||
ai.GET("/summaries", handlers.GetAISummaries)
|
||||
|
||||
// Task suggestions
|
||||
ai.POST("/tasks/suggest", handlers.GetTaskSuggestions)
|
||||
ai.GET("/tasks/suggestions", handlers.GetTaskSuggestionsList)
|
||||
ai.POST("/tasks/suggestions/:id/accept", handlers.AcceptTaskSuggestion)
|
||||
ai.POST("/tasks/suggestions/:id/dismiss", handlers.DismissTaskSuggestion)
|
||||
|
||||
// Tag suggestions
|
||||
ai.POST("/tags/suggest", handlers.GenerateTagSuggestions)
|
||||
|
||||
// Content generation
|
||||
ai.POST("/content/generate", handlers.GenerateContent)
|
||||
}
|
||||
|
||||
// Integration routes (protected)
|
||||
integrations := v1.Group("/integrations")
|
||||
integrations.Use(handlers.AuthMiddleware())
|
||||
@@ -742,19 +632,6 @@ func main() {
|
||||
teams.GET("/:id/stats", teamsHandler.GetTeamStats)
|
||||
}
|
||||
|
||||
// AI Recommendations routes (protected)
|
||||
recommendations := v1.Group("/recommendations")
|
||||
recommendations.Use(handlers.AuthMiddleware())
|
||||
{
|
||||
recommendations.GET("", aiRecommendationHandler.GetRecommendations)
|
||||
recommendations.GET("/stats", aiRecommendationHandler.GetRecommendationStats)
|
||||
recommendations.PUT("/preferences", aiRecommendationHandler.UpdatePreferences)
|
||||
recommendations.GET("/history", aiRecommendationHandler.GetRecommendationHistory)
|
||||
recommendations.GET("/insights", aiRecommendationHandler.GetInsights)
|
||||
recommendations.POST("/:id/interaction", aiRecommendationHandler.RecordInteraction)
|
||||
recommendations.DELETE("/:id", aiRecommendationHandler.DeleteRecommendation)
|
||||
}
|
||||
|
||||
// Marketplace routes (protected)
|
||||
marketplace := v1.Group("/marketplace")
|
||||
marketplace.Use(handlers.AuthMiddleware())
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AIRecommendation represents an AI-generated recommendation
|
||||
type AIRecommendation struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
// User information
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
|
||||
// Recommendation details
|
||||
RecommendationType string `json:"recommendation_type" gorm:"not null;index"` // content, task, learning, connection
|
||||
ContentType string `json:"content_type" gorm:"index"` // bookmark, note, task, course, user
|
||||
ContentID *uint `json:"content_id,omitempty" gorm:"index"`
|
||||
Title string `json:"title" gorm:"not null"`
|
||||
Description string `json:"description"`
|
||||
Reasoning string `json:"reasoning"` // Why this was recommended
|
||||
|
||||
// Content details (for display without additional queries)
|
||||
ContentTitle string `json:"content_title"`
|
||||
ContentURL string `json:"content_url"`
|
||||
ContentPreview string `json:"content_preview"`
|
||||
AuthorName string `json:"author_name"`
|
||||
Tags string `json:"tags" gorm:"serializer:json"`
|
||||
|
||||
// Recommendation metadata
|
||||
Confidence float64 `json:"confidence" gorm:"default:0.0"` // 0.0 to 1.0
|
||||
Priority string `json:"priority" gorm:"default:medium"` // low, medium, high
|
||||
Category string `json:"category"` // productivity, learning, collaboration, etc.
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
Clicked bool `json:"clicked" gorm:"default:false"`
|
||||
Dismissed bool `json:"dismissed" gorm:"default:false"`
|
||||
ClickedAt *time.Time `json:"clicked_at"`
|
||||
DismissedAt *time.Time `json:"dismissed_at"`
|
||||
|
||||
// Feedback
|
||||
Feedback string `json:"feedback"` // helpful, not_helpful, irrelevant
|
||||
FeedbackAt *time.Time `json:"feedback_at"`
|
||||
FeedbackText string `json:"feedback_text"`
|
||||
|
||||
// Source information
|
||||
SourceModel string `json:"source_model"` // Which AI model generated this
|
||||
SourceVersion string `json:"source_version"` // Version of the recommendation engine
|
||||
TrainingData string `json:"training_data"` // What data was used for training
|
||||
}
|
||||
|
||||
// UserPreference represents user preferences for recommendations
|
||||
type UserPreference struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
// User information
|
||||
UserID uint `json:"user_id" gorm:"not null;uniqueIndex"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
|
||||
// Recommendation preferences
|
||||
EnableRecommendations bool `json:"enable_recommendations" gorm:"default:true"`
|
||||
ContentRecommendations bool `json:"content_recommendations" gorm:"default:true"`
|
||||
TaskRecommendations bool `json:"task_recommendations" gorm:"default:true"`
|
||||
LearningRecommendations bool `json:"learning_recommendations" gorm:"default:true"`
|
||||
ConnectionRecommendations bool `json:"connection_recommendations" gorm:"default:false"`
|
||||
|
||||
// Frequency and timing
|
||||
MaxRecommendationsPerDay int `json:"max_recommendations_per_day" gorm:"default:5"`
|
||||
PreferredCategories []string `json:"preferred_categories" gorm:"serializer:json"`
|
||||
BlockedCategories []string `json:"blocked_categories" gorm:"serializer:json"`
|
||||
PreferredContentTypes []string `json:"preferred_content_types" gorm:"serializer:json"`
|
||||
|
||||
// Quality thresholds
|
||||
MinConfidenceThreshold float64 `json:"min_confidence_threshold" gorm:"default:0.6"`
|
||||
MaxAgeHours int `json:"max_age_hours" gorm:"default:168"` // 1 week
|
||||
|
||||
// Learning and adaptation
|
||||
EnablePersonalization bool `json:"enable_personalization" gorm:"default:true"`
|
||||
EnableFeedbackLearning bool `json:"enable_feedback_learning" gorm:"default:true"`
|
||||
LastRecommendationAt *time.Time `json:"last_recommendation_at"`
|
||||
}
|
||||
|
||||
// RecommendationInteraction tracks user interactions with recommendations
|
||||
type RecommendationInteraction struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
// Related entities
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
RecommendationID uint `json:"recommendation_id" gorm:"not null;index"`
|
||||
Recommendation AIRecommendation `json:"recommendation,omitempty" gorm:"foreignKey:RecommendationID"`
|
||||
|
||||
// Interaction details
|
||||
InteractionType string `json:"interaction_type" gorm:"not null;index"` // view, click, dismiss, feedback, share
|
||||
InteractionData string `json:"interaction_data" gorm:"serializer:json"` // Additional context
|
||||
Duration int `json:"duration"` // Time spent in seconds (for views)
|
||||
Context string `json:"context"` // Where the interaction occurred (dashboard, search, etc.)
|
||||
|
||||
// Machine learning features
|
||||
UserActivityBefore string `json:"user_activity_before"` // What user was doing before
|
||||
UserActivityAfter string `json:"user_activity_after"` // What user did after
|
||||
SessionID string `json:"session_id"`
|
||||
DeviceType string `json:"device_type"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for AIRecommendation
|
||||
func (AIRecommendation) TableName() string {
|
||||
return "ai_recommendations"
|
||||
}
|
||||
|
||||
// TableName returns the table name for UserPreference
|
||||
func (UserPreference) TableName() string {
|
||||
return "user_preferences"
|
||||
}
|
||||
|
||||
// TableName returns the table name for RecommendationInteraction
|
||||
func (RecommendationInteraction) TableName() string {
|
||||
return "recommendation_interactions"
|
||||
}
|
||||
|
||||
// BeforeCreate hooks
|
||||
func (r *AIRecommendation) BeforeCreate(tx *gorm.DB) error {
|
||||
if r.Priority == "" {
|
||||
r.Priority = "medium"
|
||||
}
|
||||
if r.Confidence == 0 {
|
||||
r.Confidence = 0.5
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (up *UserPreference) BeforeCreate(tx *gorm.DB) error {
|
||||
if up.MaxRecommendationsPerDay == 0 {
|
||||
up.MaxRecommendationsPerDay = 5
|
||||
}
|
||||
if up.MinConfidenceThreshold == 0 {
|
||||
up.MinConfidenceThreshold = 0.6
|
||||
}
|
||||
if up.MaxAgeHours == 0 {
|
||||
up.MaxAgeHours = 168 // 1 week
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// UserAISettings stores user-specific AI provider configurations
|
||||
type UserAISettings struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
UserID uint `json:"user_id" gorm:"not null;uniqueIndex"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
|
||||
// Mistral Settings
|
||||
MistralEnabled *bool `json:"mistral_enabled" gorm:"default:false"`
|
||||
MistralAPIKey string `json:"-" gorm:"column:mistral_api_key"` // Encrypted
|
||||
MistralModel string `json:"mistral_model" gorm:"default:mistral-small-latest"`
|
||||
MistralModelThinking string `json:"mistral_model_thinking" gorm:"default:mistral-large-latest"`
|
||||
|
||||
// Grok Settings
|
||||
GrokEnabled *bool `json:"grok_enabled" gorm:"default:false"`
|
||||
GrokAPIKey string `json:"-" gorm:"column:grok_api_key"` // Encrypted
|
||||
GrokBaseURL string `json:"grok_base_url" gorm:"default:https://api.x.ai/v1"`
|
||||
GrokModel string `json:"grok_model" gorm:"default:grok-4-1-fast-non-reasoning-latest"`
|
||||
GrokModelThinking string `json:"grok_model_thinking" gorm:"default:grok-4-1-fast-reasoning-latest"`
|
||||
|
||||
// DeepSeek Settings
|
||||
DeepSeekEnabled *bool `json:"deepseek_enabled" gorm:"default:false"`
|
||||
DeepSeekAPIKey string `json:"-" gorm:"column:deepseek_api_key"` // Encrypted
|
||||
DeepSeekBaseURL string `json:"deepseek_base_url" gorm:"default:https://api.deepseek.com"`
|
||||
DeepSeekModel string `json:"deepseek_model" gorm:"default:deepseek-chat"`
|
||||
DeepSeekModelThinking string `json:"deepseek_model_thinking" gorm:"default:deepseek-reasoner"`
|
||||
|
||||
// Ollama Settings
|
||||
OllamaEnabled *bool `json:"ollama_enabled" gorm:"default:false"`
|
||||
OllamaBaseURL string `json:"ollama_base_url" gorm:"default:http://localhost:11434"`
|
||||
OllamaModel string `json:"ollama_model" gorm:"default:llama3.1"`
|
||||
OllamaModelThinking string `json:"ollama_model_thinking" gorm:"default:llama3.1"`
|
||||
|
||||
// LongCat Settings
|
||||
LongCatEnabled *bool `json:"longcat_enabled" gorm:"default:false"`
|
||||
LongCatAPIKey string `json:"-" gorm:"column:longcat_api_key"` // Encrypted
|
||||
LongCatBaseURL string `json:"longcat_base_url" gorm:"default:https://api.longcat.chat"`
|
||||
LongCatOpenAIEndpoint string `json:"longcat_openai_endpoint" gorm:"default:https://api.longcat.chat/openai"`
|
||||
LongCatAnthropicEndpoint string `json:"longcat_anthropic_endpoint" gorm:"default:https://api.longcat.chat/anthropic"`
|
||||
LongCatModel string `json:"longcat_model" gorm:"default:LongCat-Flash-Chat"`
|
||||
LongCatModelThinking string `json:"longcat_model_thinking" gorm:"default:LongCat-Flash-Thinking"`
|
||||
LongCatModelThinkingUpgraded string `json:"longcat_model_thinking_upgraded" gorm:"default:LongCat-Flash-Thinking-2601"`
|
||||
LongCatFormat string `json:"longcat_format" gorm:"default:openai"`
|
||||
|
||||
// OpenRouter Settings
|
||||
OpenRouterEnabled *bool `json:"openrouter_enabled" gorm:"default:false"`
|
||||
OpenRouterAPIKey string `json:"-" gorm:"column:openrouter_api_key"` // Encrypted
|
||||
OpenRouterBaseURL string `json:"openrouter_base_url" gorm:"default:https://openrouter.ai/api"`
|
||||
OpenRouterModel string `json:"openrouter_model" gorm:"default:openrouter/auto"`
|
||||
OpenRouterModelThinking string `json:"openrouter_model_thinking" gorm:"default:openrouter/auto"`
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ChatMessage represents a chat message in the AI conversation
|
||||
type ChatMessage struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
|
||||
Content string `json:"content" gorm:"not null"`
|
||||
Role string `json:"role" gorm:"not null"` // "user" or "assistant"
|
||||
|
||||
// Session tracking
|
||||
SessionID string `json:"session_id" gorm:"not null;index"`
|
||||
|
||||
// Metadata
|
||||
TokenCount int `json:"token_count"`
|
||||
ModelUsed string `json:"model_used"`
|
||||
ProcessingMs int64 `json:"processing_ms"`
|
||||
ContextItems []string `json:"context_items" gorm:"serializer:json"` // IDs of referenced items
|
||||
}
|
||||
|
||||
// ChatSession represents a chat session
|
||||
type ChatSession struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
|
||||
Title string `json:"title"`
|
||||
|
||||
// Session metadata
|
||||
MessageCount int `json:"message_count" gorm:"default:0"`
|
||||
LastMessageAt *time.Time `json:"last_message_at"`
|
||||
|
||||
// Context configuration
|
||||
IncludeBookmarks bool `json:"include_bookmarks" gorm:"default:true"`
|
||||
IncludeTasks bool `json:"include_tasks" gorm:"default:true"`
|
||||
IncludeFiles bool `json:"include_files" gorm:"default:true"`
|
||||
IncludeNotes bool `json:"include_notes" gorm:"default:true"`
|
||||
|
||||
// Relationships
|
||||
Messages []ChatMessage `json:"messages,omitempty" gorm:"foreignKey:SessionID"`
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ConversationType represents the type of a conversation.
|
||||
type ConversationType string
|
||||
|
||||
const (
|
||||
ConversationTypeGlobal ConversationType = "global"
|
||||
ConversationTypeTeam ConversationType = "team"
|
||||
ConversationTypeGroup ConversationType = "group"
|
||||
ConversationTypeDM ConversationType = "dm"
|
||||
ConversationTypeSelf ConversationType = "self"
|
||||
ConversationTypePasswordVault ConversationType = "password_vault"
|
||||
)
|
||||
|
||||
// ConversationMemberRole represents the role of a user in a conversation.
|
||||
type ConversationMemberRole string
|
||||
|
||||
const (
|
||||
ConversationMemberRoleOwner ConversationMemberRole = "owner"
|
||||
ConversationMemberRoleAdmin ConversationMemberRole = "admin"
|
||||
ConversationMemberRoleMember ConversationMemberRole = "member"
|
||||
ConversationMemberRoleViewer ConversationMemberRole = "viewer"
|
||||
)
|
||||
|
||||
// SuggestionStatus is the lifecycle state of a message suggestion.
|
||||
type SuggestionStatus string
|
||||
|
||||
const (
|
||||
SuggestionStatusPending SuggestionStatus = "pending"
|
||||
SuggestionStatusAccepted SuggestionStatus = "accepted"
|
||||
SuggestionStatusDismissed SuggestionStatus = "dismissed"
|
||||
)
|
||||
|
||||
// Conversation is a user-to-user chat space (global/team/group/dm/self/password).
|
||||
type Conversation struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
Type ConversationType `json:"type" gorm:"not null;index"`
|
||||
Name string `json:"name" gorm:"not null"`
|
||||
Topic string `json:"topic"`
|
||||
TeamID *uint `json:"team_id,omitempty" gorm:"index"`
|
||||
Team *Team `json:"team,omitempty" gorm:"foreignKey:TeamID"`
|
||||
CreatedBy uint `json:"created_by" gorm:"not null;index"`
|
||||
Creator User `json:"creator,omitempty" gorm:"foreignKey:CreatedBy"`
|
||||
IsDefault bool `json:"is_default" gorm:"default:false;index"`
|
||||
IsArchived bool `json:"is_archived" gorm:"default:false;index"`
|
||||
|
||||
LastMessageAt *time.Time `json:"last_message_at"`
|
||||
|
||||
Members []ConversationMember `json:"members,omitempty" gorm:"foreignKey:ConversationID"`
|
||||
Messages []Message `json:"messages,omitempty" gorm:"foreignKey:ConversationID"`
|
||||
}
|
||||
|
||||
// ConversationMember links users to conversations.
|
||||
type ConversationMember struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
ConversationID uint `json:"conversation_id" gorm:"not null;index:idx_conv_member,unique"`
|
||||
UserID uint `json:"user_id" gorm:"not null;index:idx_conv_member,unique"`
|
||||
Role ConversationMemberRole `json:"role" gorm:"not null;default:member"`
|
||||
JoinedAt time.Time `json:"joined_at"`
|
||||
|
||||
LastReadMessageID *uint `json:"last_read_message_id,omitempty" gorm:"index"`
|
||||
LastReadAt *time.Time `json:"last_read_at,omitempty"`
|
||||
MutedUntil *time.Time `json:"muted_until,omitempty"`
|
||||
IsHidden bool `json:"is_hidden" gorm:"default:false"`
|
||||
|
||||
Conversation Conversation `json:"conversation,omitempty" gorm:"foreignKey:ConversationID"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
}
|
||||
|
||||
// Message is a single chat message in a conversation.
|
||||
type Message struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
ConversationID uint `json:"conversation_id" gorm:"not null;index"`
|
||||
Conversation Conversation `json:"conversation,omitempty" gorm:"foreignKey:ConversationID"`
|
||||
SenderID uint `json:"sender_id" gorm:"not null;index"`
|
||||
Sender User `json:"sender,omitempty" gorm:"foreignKey:SenderID"`
|
||||
|
||||
Body string `json:"body" gorm:"type:text"`
|
||||
IsSensitive bool `json:"is_sensitive" gorm:"default:false"`
|
||||
EditedAt *time.Time `json:"edited_at,omitempty"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty" gorm:"index"`
|
||||
|
||||
MetadataJSON string `json:"metadata_json" gorm:"type:text"`
|
||||
|
||||
Attachments []MessageAttachment `json:"attachments,omitempty" gorm:"foreignKey:MessageID"`
|
||||
References []MessageReference `json:"references,omitempty" gorm:"foreignKey:MessageID"`
|
||||
Suggestions []MessageSuggestion `json:"suggestions,omitempty" gorm:"foreignKey:MessageID"`
|
||||
Reactions []MessageReaction `json:"reactions,omitempty" gorm:"foreignKey:MessageID"`
|
||||
}
|
||||
|
||||
// MessageAttachment represents file/link-style message attachments.
|
||||
type MessageAttachment struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
MessageID uint `json:"message_id" gorm:"not null;index"`
|
||||
Message Message `json:"message,omitempty" gorm:"foreignKey:MessageID"`
|
||||
|
||||
Kind string `json:"kind" gorm:"not null;index"` // file,image,youtube,github,website,bookmark,task,event,calendar,activity,learning_path,saved_search,voice_note
|
||||
FileID *uint `json:"file_id,omitempty" gorm:"index"`
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
PreviewJSON string `json:"preview_json" gorm:"type:text"`
|
||||
}
|
||||
|
||||
// MessageReference maps chat messages to Trackeep entities.
|
||||
type MessageReference struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
MessageID uint `json:"message_id" gorm:"not null;index"`
|
||||
Message Message `json:"message,omitempty" gorm:"foreignKey:MessageID"`
|
||||
|
||||
EntityType string `json:"entity_type" gorm:"not null;index"`
|
||||
EntityID uint `json:"entity_id" gorm:"not null;index"`
|
||||
DeepLink string `json:"deep_link" gorm:"not null"`
|
||||
}
|
||||
|
||||
// MessageSuggestion stores non-blocking smart suggestions triggered by message text.
|
||||
type MessageSuggestion struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
MessageID uint `json:"message_id" gorm:"not null;index"`
|
||||
Message Message `json:"message,omitempty" gorm:"foreignKey:MessageID"`
|
||||
|
||||
Type string `json:"type" gorm:"not null;index"` // create_task, create_event, save_bookmark, ...
|
||||
PayloadJSON string `json:"payload_json" gorm:"type:text"`
|
||||
Status SuggestionStatus `json:"status" gorm:"not null;default:pending;index"`
|
||||
}
|
||||
|
||||
// MessageReaction stores emoji reactions.
|
||||
type MessageReaction struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
MessageID uint `json:"message_id" gorm:"not null;index:idx_message_reaction,unique"`
|
||||
Message Message `json:"message,omitempty" gorm:"foreignKey:MessageID"`
|
||||
UserID uint `json:"user_id" gorm:"not null;index:idx_message_reaction,unique"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
Emoji string `json:"emoji" gorm:"not null;index:idx_message_reaction,unique"`
|
||||
}
|
||||
|
||||
// PasswordVaultItem is encrypted secret data owned by a user.
|
||||
type PasswordVaultItem struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
OwnerUserID uint `json:"owner_user_id" gorm:"not null;index"`
|
||||
OwnerUser User `json:"owner_user,omitempty" gorm:"foreignKey:OwnerUserID"`
|
||||
|
||||
Label string `json:"label" gorm:"not null"`
|
||||
EncryptedSecret string `json:"-" gorm:"type:text;not null"`
|
||||
EncryptedNotes string `json:"-" gorm:"type:text"`
|
||||
SourceMessageID *uint `json:"source_message_id,omitempty" gorm:"index"`
|
||||
SourceMessage *Message `json:"source_message,omitempty" gorm:"foreignKey:SourceMessageID"`
|
||||
CreatedBy uint `json:"created_by" gorm:"not null;index"`
|
||||
LastAccessedAt *time.Time `json:"last_accessed_at,omitempty"`
|
||||
|
||||
Shares []PasswordVaultShare `json:"shares,omitempty" gorm:"foreignKey:VaultItemID"`
|
||||
}
|
||||
|
||||
// PasswordVaultShare controls explicit sharing of vault items.
|
||||
type PasswordVaultShare struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
VaultItemID uint `json:"vault_item_id" gorm:"not null;index"`
|
||||
VaultItem PasswordVaultItem `json:"vault_item,omitempty" gorm:"foreignKey:VaultItemID"`
|
||||
|
||||
SharedByUserID uint `json:"shared_by_user_id" gorm:"not null;index"`
|
||||
SharedByUser User `json:"shared_by_user,omitempty" gorm:"foreignKey:SharedByUserID"`
|
||||
TargetConversationID uint `json:"target_conversation_id" gorm:"not null;index"`
|
||||
TargetConversation Conversation `json:"target_conversation,omitempty" gorm:"foreignKey:TargetConversationID"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
AllowReveal bool `json:"allow_reveal" gorm:"default:false"`
|
||||
}
|
||||
+12
-39
@@ -127,36 +127,18 @@ func AutoMigrate() error {
|
||||
{name: "BrowserExtension", model: &BrowserExtension{}},
|
||||
{name: "TimeEntry", model: &TimeEntry{}},
|
||||
{name: "FileAnalysis", model: &FileAnalysis{}},
|
||||
{name: "ChatSession", model: &ChatSession{}},
|
||||
{name: "ChatMessage", model: &ChatMessage{}},
|
||||
{name: "LearningPath", model: &LearningPath{}},
|
||||
{name: "LearningModule", model: &LearningModule{}},
|
||||
{name: "ModuleResource", model: &ModuleResource{}},
|
||||
{name: "Enrollment", model: &Enrollment{}},
|
||||
{name: "Progress", model: &Progress{}},
|
||||
{name: "Course", model: &Course{}},
|
||||
{name: "LearningPathCourse", model: &LearningPathCourse{}},
|
||||
{name: "CalendarEvent", model: &CalendarEvent{}},
|
||||
{name: "RecurrenceRule", model: &RecurrenceRule{}},
|
||||
{name: "CalendarSettings", model: &CalendarSettings{}},
|
||||
{name: "ContentEmbedding", model: &ContentEmbedding{}},
|
||||
{name: "SavedSearch", model: &SavedSearch{}},
|
||||
{name: "SavedSearchTag", model: &SavedSearchTag{}},
|
||||
{name: "SearchAnalytics", model: &SearchAnalytics{}},
|
||||
{name: "SearchSuggestion", model: &SearchSuggestion{}},
|
||||
{name: "AISummary", model: &AISummary{}},
|
||||
{name: "AITaskSuggestion", model: &AITaskSuggestion{}},
|
||||
{name: "UserAISettings", model: &UserAISettings{}},
|
||||
{name: "UserSearchSettings", model: &UserSearchSettings{}},
|
||||
{name: "UserUpdateSettings", model: &UserUpdateSettings{}},
|
||||
{name: "AITagSuggestion", model: &AITagSuggestion{}},
|
||||
{name: "AIContentGeneration", model: &AIContentGeneration{}},
|
||||
{name: "AICodeReview", model: &AICodeReview{}},
|
||||
{name: "AILearningRecommendation", model: &AILearningRecommendation{}},
|
||||
{name: "AIRecommendation", model: &AIRecommendation{}},
|
||||
{name: "UserPreference", model: &UserPreference{}},
|
||||
{name: "RecommendationInteraction", model: &RecommendationInteraction{}},
|
||||
{name: "Integration", model: &Integration{}},
|
||||
{name: "LearningPath", model: &LearningPath{}},
|
||||
{name: "LearningModule", model: &LearningModule{}},
|
||||
{name: "ModuleResource", model: &ModuleResource{}},
|
||||
{name: "Enrollment", model: &Enrollment{}},
|
||||
{name: "Progress", model: &Progress{}},
|
||||
{name: "Course", model: &Course{}},
|
||||
{name: "LearningPathCourse", model: &LearningPathCourse{}},
|
||||
{name: "CalendarEvent", model: &CalendarEvent{}},
|
||||
{name: "RecurrenceRule", model: &RecurrenceRule{}},
|
||||
{name: "CalendarSettings", model: &CalendarSettings{}},
|
||||
{name: "UserUpdateSettings", model: &UserUpdateSettings{}},
|
||||
{name: "Integration", model: &Integration{}},
|
||||
{name: "SyncLog", model: &SyncLog{}},
|
||||
{name: "WebhookEvent", model: &WebhookEvent{}},
|
||||
{name: "ControlServiceSession", model: &ControlServiceSession{}},
|
||||
@@ -208,15 +190,6 @@ func AutoMigrate() error {
|
||||
{name: "MentorshipRequest", model: &MentorshipRequest{}},
|
||||
{name: "YouTubeChannelCache", model: &YouTubeChannelCache{}},
|
||||
{name: "VideoBookmark", model: &VideoBookmark{}},
|
||||
{name: "Conversation", model: &Conversation{}},
|
||||
{name: "ConversationMember", model: &ConversationMember{}},
|
||||
{name: "Message", model: &Message{}},
|
||||
{name: "MessageAttachment", model: &MessageAttachment{}},
|
||||
{name: "MessageReference", model: &MessageReference{}},
|
||||
{name: "MessageSuggestion", model: &MessageSuggestion{}},
|
||||
{name: "MessageReaction", model: &MessageReaction{}},
|
||||
{name: "PasswordVaultItem", model: &PasswordVaultItem{}},
|
||||
{name: "PasswordVaultShare", model: &PasswordVaultShare{}},
|
||||
}
|
||||
|
||||
criticalModels := map[string]bool{
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ContentEmbedding stores vector embeddings for semantic search
|
||||
type ContentEmbedding struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
// Content reference
|
||||
ContentType string `json:"content_type" gorm:"not null;index"` // 'bookmark', 'task', 'note', 'file'
|
||||
ContentID uint `json:"content_id" gorm:"not null;index"`
|
||||
|
||||
// Embedding data
|
||||
Embedding string `json:"embedding" gorm:"type:text"` // JSON array of floats
|
||||
Model string `json:"model" gorm:"not null"` // AI model used
|
||||
Dimensions int `json:"dimensions" gorm:"not null"` // Vector dimensions
|
||||
TextContent string `json:"text_content" gorm:"type:text"` // Original text for embedding
|
||||
|
||||
// Metadata
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
}
|
||||
|
||||
// SavedSearch represents a user's saved search query
|
||||
type SavedSearch struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
|
||||
Name string `json:"name" gorm:"not null"`
|
||||
Query string `json:"query" gorm:"not null"`
|
||||
Filters string `json:"filters" gorm:"type:json"` // JSON serialized filters
|
||||
Alert bool `json:"alert" gorm:"default:false"`
|
||||
LastRun *time.Time `json:"last_run"`
|
||||
RunCount int `json:"run_count" gorm:"default:0"`
|
||||
IsPublic bool `json:"is_public" gorm:"default:false"`
|
||||
Description string `json:"description"`
|
||||
Tags []SavedSearchTag `json:"tags,omitempty" gorm:"many2many:saved_search_tags;"`
|
||||
}
|
||||
|
||||
// SavedSearchTag represents tags for saved searches
|
||||
type SavedSearchTag struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
Name string `json:"name" gorm:"unique;not null"`
|
||||
Color string `json:"color" gorm:"default:#3b82f6"`
|
||||
}
|
||||
|
||||
// SearchAnalytics stores search analytics data
|
||||
type SearchAnalytics struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
|
||||
Query string `json:"query" gorm:"not null;index"`
|
||||
Filters string `json:"filters" gorm:"type:json"`
|
||||
ResultsCount int `json:"results_count"`
|
||||
Took int `json:"took"` // Time in milliseconds
|
||||
ContentType string `json:"content_type"`
|
||||
ClickedResultID *uint `json:"clicked_result_id"` // Track which result was clicked
|
||||
SessionID string `json:"session_id" gorm:"index"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
}
|
||||
|
||||
// SearchSuggestion represents search suggestions
|
||||
type SearchSuggestion struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
Text string `json:"text" gorm:"not null;uniqueIndex"`
|
||||
Type string `json:"type" gorm:"not null"` // 'query', 'tag', 'content'
|
||||
Frequency int `json:"frequency" gorm:"default:1"`
|
||||
LastUsed time.Time `json:"last_used"`
|
||||
ContentType *string `json:"content_type,omitempty"`
|
||||
IsPublic bool `json:"is_public" gorm:"default:true"`
|
||||
}
|
||||
|
||||
// BeforeCreate hook for ContentEmbedding
|
||||
func (ce *ContentEmbedding) BeforeCreate(tx *gorm.DB) error {
|
||||
// Set default model if not specified
|
||||
if ce.Model == "" {
|
||||
ce.Model = "text-embedding-ada-002"
|
||||
}
|
||||
|
||||
// Set default dimensions if not specified
|
||||
if ce.Dimensions == 0 {
|
||||
ce.Dimensions = 1536 // Default for OpenAI embeddings
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,553 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/trackeep/backend/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AIRecommendationService provides AI-powered recommendations
|
||||
type AIRecommendationService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewAIRecommendationService creates a new AI recommendation service
|
||||
func NewAIRecommendationService(db *gorm.DB) *AIRecommendationService {
|
||||
return &AIRecommendationService{db: db}
|
||||
}
|
||||
|
||||
// RecommendationRequest represents a request for recommendations
|
||||
type RecommendationRequest struct {
|
||||
UserID uint `json:"user_id"`
|
||||
RecommendationType string `json:"recommendation_type"` // content, task, learning, connection
|
||||
Limit int `json:"limit"` // Max recommendations to return
|
||||
MinConfidence float64 `json:"min_confidence"` // Minimum confidence threshold
|
||||
IncludeDismissed bool `json:"include_dismissed"` // Include previously dismissed items
|
||||
Context string `json:"context"` // Current user context
|
||||
}
|
||||
|
||||
// RecommendationScore represents a scored recommendation
|
||||
type RecommendationScore struct {
|
||||
Recommendation models.AIRecommendation
|
||||
Score float64
|
||||
Reason string
|
||||
}
|
||||
|
||||
// GetRecommendations generates personalized recommendations for a user
|
||||
func (s *AIRecommendationService) GetRecommendations(req RecommendationRequest) ([]models.AIRecommendation, error) {
|
||||
// Get user preferences
|
||||
var prefs models.UserPreference
|
||||
if err := s.db.Where("user_id = ?", req.UserID).First(&prefs).Error; err != nil {
|
||||
// Create default preferences if not found
|
||||
prefs = models.UserPreference{
|
||||
UserID: req.UserID,
|
||||
EnableRecommendations: true,
|
||||
MinConfidenceThreshold: 0.6,
|
||||
MaxRecommendationsPerDay: 5,
|
||||
MaxAgeHours: 168,
|
||||
}
|
||||
s.db.Create(&prefs)
|
||||
}
|
||||
|
||||
if !prefs.EnableRecommendations {
|
||||
return []models.AIRecommendation{}, nil
|
||||
}
|
||||
|
||||
// Check daily limit
|
||||
today := time.Now().Format("2006-01-02")
|
||||
var todayCount int64
|
||||
s.db.Model(&models.AIRecommendation{}).
|
||||
Where("user_id = ? AND DATE(created_at) = ?", req.UserID, today).
|
||||
Count(&todayCount)
|
||||
|
||||
if int(todayCount) >= prefs.MaxRecommendationsPerDay {
|
||||
return []models.AIRecommendation{}, nil
|
||||
}
|
||||
|
||||
// Generate recommendations based on type
|
||||
var scoredRecommendations []RecommendationScore
|
||||
|
||||
switch req.RecommendationType {
|
||||
case "content":
|
||||
scoredRecommendations = s.generateContentRecommendations(req.UserID, &prefs)
|
||||
case "task":
|
||||
scoredRecommendations = s.generateTaskRecommendations(req.UserID, &prefs)
|
||||
case "learning":
|
||||
scoredRecommendations = s.generateLearningRecommendations(req.UserID, &prefs)
|
||||
case "connection":
|
||||
scoredRecommendations = s.generateConnectionRecommendations(req.UserID, &prefs)
|
||||
default:
|
||||
// Generate mixed recommendations
|
||||
scoredRecommendations = s.generateMixedRecommendations(req.UserID, &prefs)
|
||||
}
|
||||
|
||||
// Filter by confidence and dismissed status
|
||||
minConf := req.MinConfidence
|
||||
if minConf == 0 {
|
||||
minConf = prefs.MinConfidenceThreshold
|
||||
}
|
||||
|
||||
var filtered []RecommendationScore
|
||||
for _, rec := range scoredRecommendations {
|
||||
if rec.Score >= minConf && (req.IncludeDismissed || !rec.Recommendation.Dismissed) {
|
||||
// Check if not expired
|
||||
if rec.Recommendation.ExpiresAt == nil || rec.Recommendation.ExpiresAt.After(time.Now()) {
|
||||
filtered = append(filtered, rec)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by score
|
||||
sort.Slice(filtered, func(i, j int) bool {
|
||||
return filtered[i].Score > filtered[j].Score
|
||||
})
|
||||
|
||||
// Apply limit
|
||||
limit := req.Limit
|
||||
if limit == 0 {
|
||||
limit = 5
|
||||
}
|
||||
if limit > len(filtered) {
|
||||
limit = len(filtered)
|
||||
}
|
||||
|
||||
// Save recommendations to database
|
||||
var recommendations []models.AIRecommendation
|
||||
for i := 0; i < limit; i++ {
|
||||
rec := filtered[i].Recommendation
|
||||
rec.Confidence = filtered[i].Score
|
||||
|
||||
// Check if already exists
|
||||
var existing models.AIRecommendation
|
||||
err := s.db.Where("user_id = ? AND content_type = ? AND content_id = ?",
|
||||
rec.UserID, rec.ContentType, rec.ContentID).First(&existing).Error
|
||||
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
// Create new recommendation
|
||||
if err := s.db.Create(&rec).Error; err != nil {
|
||||
continue
|
||||
}
|
||||
recommendations = append(recommendations, rec)
|
||||
} else {
|
||||
// Update existing recommendation
|
||||
existing.Confidence = rec.Confidence
|
||||
existing.Reasoning = filtered[i].Reason
|
||||
existing.UpdatedAt = time.Now()
|
||||
s.db.Save(&existing)
|
||||
recommendations = append(recommendations, existing)
|
||||
}
|
||||
}
|
||||
|
||||
return recommendations, nil
|
||||
}
|
||||
|
||||
// generateContentRecommendations generates content-based recommendations
|
||||
func (s *AIRecommendationService) generateContentRecommendations(userID uint, prefs *models.UserPreference) []RecommendationScore {
|
||||
var recommendations []RecommendationScore
|
||||
|
||||
// Get user's recent activity and interests
|
||||
userTags := s.getUserInterests(userID)
|
||||
userCategories := s.getUserCategories(userID)
|
||||
|
||||
// Find similar content from other users
|
||||
var similarContent []struct {
|
||||
ContentType string
|
||||
ContentID uint
|
||||
Title string
|
||||
URL string
|
||||
Preview string
|
||||
Author string
|
||||
Tags string
|
||||
Score float64
|
||||
}
|
||||
|
||||
// Query for popular content among similar users
|
||||
query := `
|
||||
SELECT
|
||||
b.content_type,
|
||||
b.content_id,
|
||||
b.title,
|
||||
b.url,
|
||||
SUBSTRING(b.content, 1, 200) as preview,
|
||||
u.full_name as author,
|
||||
b.tags,
|
||||
COUNT(*) as interaction_count
|
||||
FROM bookmarks b
|
||||
INNER JOIN users u ON b.user_id = u.id
|
||||
WHERE b.user_id != ?
|
||||
AND b.created_at > ?
|
||||
AND (b.tags ?| array[?] OR b.content_type = ANY(?))
|
||||
GROUP BY b.content_type, b.content_id, b.title, b.url, b.content, u.full_name, b.tags
|
||||
HAVING COUNT(*) > 1
|
||||
ORDER BY interaction_count DESC
|
||||
LIMIT 20
|
||||
`
|
||||
|
||||
// This is a simplified version - in practice you'd use more sophisticated similarity algorithms
|
||||
s.db.Raw(query, userID, time.Now().AddDate(0, -3, 0), userTags, prefs.PreferredContentTypes).Scan(&similarContent)
|
||||
|
||||
for _, content := range similarContent {
|
||||
score := s.calculateContentScore(content, userTags, userCategories, prefs)
|
||||
|
||||
expiresAt := time.Now().Add(time.Hour * 24 * 7)
|
||||
|
||||
recommendation := models.AIRecommendation{
|
||||
UserID: userID,
|
||||
RecommendationType: "content",
|
||||
ContentType: content.ContentType,
|
||||
ContentID: &content.ContentID,
|
||||
Title: content.Title,
|
||||
Description: fmt.Sprintf("Recommended based on your interests in %s", strings.Join(userTags, ", ")),
|
||||
ContentTitle: content.Title,
|
||||
ContentURL: content.URL,
|
||||
ContentPreview: content.Preview,
|
||||
AuthorName: content.Author,
|
||||
Tags: content.Tags,
|
||||
Priority: s.getPriorityFromScore(score),
|
||||
ExpiresAt: &expiresAt,
|
||||
SourceModel: "collaborative_filtering_v1",
|
||||
}
|
||||
|
||||
recommendations = append(recommendations, RecommendationScore{
|
||||
Recommendation: recommendation,
|
||||
Score: score,
|
||||
Reason: fmt.Sprintf("Similar users with interests in %s also liked this", strings.Join(userTags[:2], ", ")),
|
||||
})
|
||||
}
|
||||
|
||||
return recommendations
|
||||
}
|
||||
|
||||
// generateTaskRecommendations generates task-based recommendations
|
||||
func (s *AIRecommendationService) generateTaskRecommendations(userID uint, prefs *models.UserPreference) []RecommendationScore {
|
||||
var recommendations []RecommendationScore
|
||||
|
||||
// Get user's task patterns and upcoming deadlines
|
||||
var userTasks []models.Task
|
||||
s.db.Where("user_id = ? AND status != 'completed'", userID).Find(&userTasks)
|
||||
|
||||
// Get calendar events for context
|
||||
var upcomingEvents []models.CalendarEvent
|
||||
s.db.Where("user_id = ? AND start_time BETWEEN ? AND ?",
|
||||
userID, time.Now(), time.Now().AddDate(0, 0, 7)).Find(&upcomingEvents)
|
||||
|
||||
// Analyze patterns and suggest tasks
|
||||
for _, task := range userTasks {
|
||||
if string(task.Priority) == "high" && task.DueDate != nil && task.DueDate.Before(time.Now().AddDate(0, 0, 3)) {
|
||||
// High priority task due soon
|
||||
score := 0.9
|
||||
|
||||
// Convert tags to string
|
||||
var tagStr string
|
||||
for i, tag := range task.Tags {
|
||||
if i > 0 {
|
||||
tagStr += ","
|
||||
}
|
||||
tagStr += tag.Name
|
||||
}
|
||||
|
||||
expiresAt := task.DueDate
|
||||
|
||||
recommendation := models.AIRecommendation{
|
||||
UserID: userID,
|
||||
RecommendationType: "task",
|
||||
ContentType: "task",
|
||||
ContentID: &task.ID,
|
||||
Title: fmt.Sprintf("Focus on: %s", task.Title),
|
||||
Description: fmt.Sprintf("This high-priority task is due on %s", task.DueDate.Format("Jan 2")),
|
||||
ContentTitle: task.Title,
|
||||
ContentPreview: task.Description,
|
||||
Tags: tagStr,
|
||||
Priority: "high",
|
||||
Confidence: score,
|
||||
ExpiresAt: expiresAt,
|
||||
SourceModel: "deadline_priority_v1",
|
||||
}
|
||||
|
||||
recommendations = append(recommendations, RecommendationScore{
|
||||
Recommendation: recommendation,
|
||||
Score: score,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return recommendations
|
||||
}
|
||||
|
||||
// generateLearningRecommendations generates learning-based recommendations
|
||||
func (s *AIRecommendationService) generateLearningRecommendations(userID uint, prefs *models.UserPreference) []RecommendationScore {
|
||||
var recommendations []RecommendationScore
|
||||
|
||||
// Get user's learning progress and interests
|
||||
var enrollments []models.Enrollment
|
||||
s.db.Preload("Course").Preload("LearningPath").Where("user_id = ?", userID).Find(&enrollments)
|
||||
|
||||
var completedCategories []string
|
||||
var inProgressCategories []string
|
||||
for _, enrollment := range enrollments {
|
||||
if enrollment.Progress >= 100 {
|
||||
if enrollment.CourseID != nil && enrollment.Course != nil {
|
||||
completedCategories = append(completedCategories, enrollment.Course.Category)
|
||||
} else if enrollment.LearningPathID != 0 {
|
||||
completedCategories = append(completedCategories, enrollment.LearningPath.Category)
|
||||
}
|
||||
} else {
|
||||
if enrollment.CourseID != nil && enrollment.Course != nil {
|
||||
inProgressCategories = append(inProgressCategories, enrollment.Course.Category)
|
||||
} else if enrollment.LearningPathID != 0 {
|
||||
inProgressCategories = append(inProgressCategories, enrollment.LearningPath.Category)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recommend next courses based on completed ones
|
||||
for _, category := range completedCategories {
|
||||
var nextCourses []models.Course
|
||||
s.db.Where("category = ? AND level != ?", category, "beginner").Limit(3).Find(&nextCourses)
|
||||
|
||||
for _, course := range nextCourses {
|
||||
score := 0.8
|
||||
|
||||
// Convert topics to tags string
|
||||
var tagsStr string
|
||||
for i, topic := range course.Topics {
|
||||
if i > 0 {
|
||||
tagsStr += ","
|
||||
}
|
||||
tagsStr += topic
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Add(time.Hour * 24 * 14)
|
||||
|
||||
recommendation := models.AIRecommendation{
|
||||
UserID: userID,
|
||||
RecommendationType: "learning",
|
||||
ContentType: "course",
|
||||
ContentID: &course.ID,
|
||||
Title: fmt.Sprintf("Continue learning: %s", course.Title),
|
||||
Description: fmt.Sprintf("Based on your completion of %s courses", category),
|
||||
ContentTitle: course.Title,
|
||||
ContentPreview: course.Description,
|
||||
Tags: tagsStr,
|
||||
Priority: "medium",
|
||||
Confidence: score,
|
||||
ExpiresAt: &expiresAt,
|
||||
SourceModel: "learning_path_v1",
|
||||
}
|
||||
|
||||
recommendations = append(recommendations, RecommendationScore{
|
||||
Recommendation: recommendation,
|
||||
Score: score,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return recommendations
|
||||
}
|
||||
|
||||
// generateConnectionRecommendations generates user connection recommendations
|
||||
func (s *AIRecommendationService) generateConnectionRecommendations(userID uint, prefs *models.UserPreference) []RecommendationScore {
|
||||
var recommendations []RecommendationScore
|
||||
|
||||
if !prefs.ConnectionRecommendations {
|
||||
return recommendations
|
||||
}
|
||||
|
||||
// Get user's skills and interests
|
||||
var user models.User
|
||||
s.db.Preload("Skills").Where("id = ?", userID).First(&user)
|
||||
|
||||
// Find similar users
|
||||
var similarUsers []models.User
|
||||
s.db.Where("id != ? AND (skills ?| array[?] OR location = ?)",
|
||||
userID, s.getSkillNames(user.Skills), user.Location).Limit(5).Find(&similarUsers)
|
||||
|
||||
for _, similarUser := range similarUsers {
|
||||
score := s.calculateUserSimilarity(&user, &similarUser)
|
||||
|
||||
if score > 0.6 {
|
||||
expiresAt := time.Now().Add(time.Hour * 24 * 30)
|
||||
|
||||
recommendation := models.AIRecommendation{
|
||||
UserID: userID,
|
||||
RecommendationType: "connection",
|
||||
ContentType: "user",
|
||||
ContentID: &similarUser.ID,
|
||||
Title: fmt.Sprintf("Connect with %s", similarUser.FullName),
|
||||
Description: fmt.Sprintf("Similar interests in %s", s.getSharedInterests(&user, &similarUser)),
|
||||
ContentTitle: similarUser.FullName,
|
||||
ContentPreview: similarUser.Bio,
|
||||
AuthorName: similarUser.JobTitle,
|
||||
Priority: "low",
|
||||
Confidence: score,
|
||||
ExpiresAt: &expiresAt,
|
||||
SourceModel: "user_similarity_v1",
|
||||
}
|
||||
|
||||
recommendations = append(recommendations, RecommendationScore{
|
||||
Recommendation: recommendation,
|
||||
Score: score,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return recommendations
|
||||
}
|
||||
|
||||
// generateMixedRecommendations generates a mix of all recommendation types
|
||||
func (s *AIRecommendationService) generateMixedRecommendations(userID uint, prefs *models.UserPreference) []RecommendationScore {
|
||||
var allRecommendations []RecommendationScore
|
||||
|
||||
// Get recommendations from all types
|
||||
contentRecs := s.generateContentRecommendations(userID, prefs)
|
||||
taskRecs := s.generateTaskRecommendations(userID, prefs)
|
||||
learningRecs := s.generateLearningRecommendations(userID, prefs)
|
||||
connectionRecs := s.generateConnectionRecommendations(userID, prefs)
|
||||
|
||||
allRecommendations = append(allRecommendations, contentRecs...)
|
||||
allRecommendations = append(allRecommendations, taskRecs...)
|
||||
allRecommendations = append(allRecommendations, learningRecs...)
|
||||
allRecommendations = append(allRecommendations, connectionRecs...)
|
||||
|
||||
// Ensure diverse mix by limiting each type
|
||||
maxPerType := 2
|
||||
typeCounts := make(map[string]int)
|
||||
var mixedRecs []RecommendationScore
|
||||
|
||||
for _, rec := range allRecommendations {
|
||||
if typeCounts[rec.Recommendation.ContentType] < maxPerType {
|
||||
mixedRecs = append(mixedRecs, rec)
|
||||
typeCounts[rec.Recommendation.ContentType]++
|
||||
}
|
||||
}
|
||||
|
||||
return mixedRecs
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func (s *AIRecommendationService) getUserInterests(userID uint) []string {
|
||||
var tags []string
|
||||
s.db.Model(&models.Bookmark{}).
|
||||
Select("DISTINCT unnest(string_to_array(tags, ',')) as tag").
|
||||
Where("user_id = ?", userID).
|
||||
Pluck("tag", &tags)
|
||||
return tags
|
||||
}
|
||||
|
||||
func (s *AIRecommendationService) getUserCategories(userID uint) []string {
|
||||
var categories []string
|
||||
s.db.Model(&models.Course{}).
|
||||
Select("DISTINCT category").
|
||||
Joins("JOIN enrollments ON courses.id = enrollments.course_id").
|
||||
Where("enrollments.user_id = ?", userID).
|
||||
Pluck("category", &categories)
|
||||
return categories
|
||||
}
|
||||
|
||||
func (s *AIRecommendationService) calculateContentScore(content interface{}, userTags, userCategories []string, prefs *models.UserPreference) float64 {
|
||||
// Simplified scoring algorithm
|
||||
score := 0.5 // Base score
|
||||
|
||||
// Add points for tag matches
|
||||
// In practice, this would be more sophisticated
|
||||
score += float64(len(userTags)) * 0.1
|
||||
|
||||
// Ensure score is within bounds
|
||||
if score > 1.0 {
|
||||
score = 1.0
|
||||
}
|
||||
if score < 0.0 {
|
||||
score = 0.0
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
func (s *AIRecommendationService) getPriorityFromScore(score float64) string {
|
||||
if score >= 0.8 {
|
||||
return "high"
|
||||
} else if score >= 0.6 {
|
||||
return "medium"
|
||||
}
|
||||
return "low"
|
||||
}
|
||||
|
||||
func (s *AIRecommendationService) getSkillNames(skills []models.Skill) []string {
|
||||
var names []string
|
||||
for _, skill := range skills {
|
||||
names = append(names, skill.Name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func (s *AIRecommendationService) calculateUserSimilarity(user1, user2 *models.User) float64 {
|
||||
// Simplified similarity calculation
|
||||
score := 0.0
|
||||
|
||||
// Location match
|
||||
if user1.Location == user2.Location && user1.Location != "" {
|
||||
score += 0.3
|
||||
}
|
||||
|
||||
// Skills overlap (simplified)
|
||||
if len(user1.Skills) > 0 && len(user2.Skills) > 0 {
|
||||
score += 0.4
|
||||
}
|
||||
|
||||
// Random factor for demo
|
||||
score += 0.2
|
||||
|
||||
if score > 1.0 {
|
||||
score = 1.0
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
func (s *AIRecommendationService) getSharedInterests(user1, user2 *models.User) string {
|
||||
// Simplified - in practice would analyze actual shared interests
|
||||
interests := []string{"technology", "productivity", "learning"}
|
||||
if len(interests) > 2 {
|
||||
return strings.Join(interests[:2], ", ")
|
||||
}
|
||||
return strings.Join(interests, ", ")
|
||||
}
|
||||
|
||||
// RecordInteraction records user interaction with recommendations
|
||||
func (s *AIRecommendationService) RecordInteraction(userID, recommendationID uint, interactionType, context string) error {
|
||||
interaction := models.RecommendationInteraction{
|
||||
UserID: userID,
|
||||
RecommendationID: recommendationID,
|
||||
InteractionType: interactionType,
|
||||
Context: context,
|
||||
SessionID: fmt.Sprintf("session_%d_%d", userID, time.Now().Unix()),
|
||||
DeviceType: "web", // Would be detected from request
|
||||
}
|
||||
|
||||
// Update recommendation based on interaction
|
||||
var recommendation models.AIRecommendation
|
||||
if err := s.db.First(&recommendation, recommendationID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch interactionType {
|
||||
case "click":
|
||||
recommendation.Clicked = true
|
||||
now := time.Now()
|
||||
recommendation.ClickedAt = &now
|
||||
case "dismiss":
|
||||
recommendation.Dismissed = true
|
||||
now := time.Now()
|
||||
recommendation.DismissedAt = &now
|
||||
case "feedback":
|
||||
// Additional feedback handling would go here
|
||||
}
|
||||
|
||||
s.db.Save(&recommendation)
|
||||
return s.db.Create(&interaction).Error
|
||||
}
|
||||
@@ -1,532 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AIProvider represents different AI providers
|
||||
type AIProvider string
|
||||
|
||||
const (
|
||||
ProviderMistral AIProvider = "mistral"
|
||||
ProviderLongCat AIProvider = "longcat"
|
||||
ProviderGrok AIProvider = "grok"
|
||||
ProviderDeepSeek AIProvider = "deepseek"
|
||||
ProviderOllama AIProvider = "ollama"
|
||||
ProviderOpenRouter AIProvider = "openrouter"
|
||||
)
|
||||
|
||||
// AIRequest represents a generic AI request
|
||||
type AIRequest struct {
|
||||
Messages []Message `json:"messages"`
|
||||
Model string `json:"model"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
ModelType string `json:"model_type,omitempty"` // "standard", "thinking", "upgraded_thinking"
|
||||
}
|
||||
|
||||
// Message represents a chat message
|
||||
type Message struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||
}
|
||||
|
||||
// AIResponse represents a generic AI response
|
||||
type AIResponse struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created"`
|
||||
Model string `json:"model"`
|
||||
Choices []Choice `json:"choices"`
|
||||
Usage Usage `json:"usage"`
|
||||
}
|
||||
|
||||
// Choice represents a choice in AI response
|
||||
type Choice struct {
|
||||
Index int `json:"index"`
|
||||
Message Message `json:"message"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
}
|
||||
|
||||
// Usage represents token usage
|
||||
type Usage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
}
|
||||
|
||||
// AIService handles multiple AI providers
|
||||
type AIService struct {
|
||||
provider AIProvider
|
||||
}
|
||||
|
||||
// NewAIService creates a new AI service with the specified provider
|
||||
func NewAIService(provider AIProvider) *AIService {
|
||||
return &AIService{provider: provider}
|
||||
}
|
||||
|
||||
// GetAvailableProviders returns available AI providers
|
||||
func GetAvailableProviders() []AIProvider {
|
||||
// Return all known providers so the frontend can show them in settings
|
||||
// regardless of current environment configuration. Environment flags
|
||||
// and API keys still control whether requests actually succeed.
|
||||
return []AIProvider{
|
||||
ProviderMistral,
|
||||
ProviderLongCat,
|
||||
ProviderGrok,
|
||||
ProviderDeepSeek,
|
||||
ProviderOllama,
|
||||
ProviderOpenRouter,
|
||||
}
|
||||
}
|
||||
|
||||
// ChatCompletion sends a chat completion request to the configured provider
|
||||
func (s *AIService) ChatCompletion(req AIRequest) (*AIResponse, error) {
|
||||
switch s.provider {
|
||||
case ProviderMistral:
|
||||
return s.callMistral(req)
|
||||
case ProviderLongCat:
|
||||
return s.callLongCat(req)
|
||||
case ProviderGrok:
|
||||
return s.callGrok(req)
|
||||
case ProviderDeepSeek:
|
||||
return s.callDeepSeek(req)
|
||||
case ProviderOllama:
|
||||
return s.callOllama(req)
|
||||
case ProviderOpenRouter:
|
||||
return s.callOpenRouter(req)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported AI provider: %s", s.provider)
|
||||
}
|
||||
}
|
||||
|
||||
// ChatCompletionWithThinking sends a chat completion request with thinking model
|
||||
func (s *AIService) ChatCompletionWithThinking(req AIRequest) (*AIResponse, error) {
|
||||
// Override model with thinking model
|
||||
thinkingModel := s.getThinkingModel()
|
||||
if thinkingModel != "" {
|
||||
req.Model = thinkingModel
|
||||
}
|
||||
|
||||
return s.ChatCompletion(req)
|
||||
}
|
||||
|
||||
// ChatCompletionWithUpgradedThinking sends a chat completion request with upgraded thinking model (LongCat only)
|
||||
func (s *AIService) ChatCompletionWithUpgradedThinking(req AIRequest) (*AIResponse, error) {
|
||||
if s.provider != ProviderLongCat {
|
||||
return nil, fmt.Errorf("upgraded thinking model only available for LongCat provider")
|
||||
}
|
||||
|
||||
// Override model with upgraded thinking model
|
||||
upgradedModel := os.Getenv("LONGCAT_MODEL_THINKING_UPGRADED")
|
||||
if upgradedModel != "" {
|
||||
req.Model = upgradedModel
|
||||
}
|
||||
|
||||
return s.ChatCompletion(req)
|
||||
}
|
||||
|
||||
// ParseThinkingResponse extracts the actual content from thinking model responses
|
||||
func ParseThinkingResponse(resp *AIResponse, provider AIProvider, modelType string) string {
|
||||
if provider == ProviderLongCat {
|
||||
// Handle LongCat thinking models
|
||||
if resp.Choices[0].Message.Content != "" {
|
||||
content := resp.Choices[0].Message.Content
|
||||
|
||||
// For LongCat-Flash-Thinking, remove thinking tags
|
||||
if strings.Contains(content, "<longcat_think>") {
|
||||
// Extract content after thinking tags
|
||||
parts := strings.Split(content, "</longcat_think>")
|
||||
if len(parts) > 1 {
|
||||
return strings.TrimSpace(parts[1])
|
||||
}
|
||||
// If no closing tag, try to extract after the thinking content
|
||||
lines := strings.Split(content, "\n")
|
||||
for i, line := range lines {
|
||||
if strings.Contains(line, "</longcat_think>") {
|
||||
return strings.TrimSpace(strings.Join(lines[i+1:], "\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return content
|
||||
} else if resp.Choices[0].Message.ReasoningContent != "" {
|
||||
// For LongCat-Flash-Thinking-2601, check if there's actual content
|
||||
// This model puts reasoning in reasoning_content and final answer in content
|
||||
// If content is null, we might need to extract from reasoning or return the reasoning itself
|
||||
return resp.Choices[0].Message.ReasoningContent
|
||||
}
|
||||
}
|
||||
|
||||
// For Grok, DeepSeek, Mistral and other providers, or if no special handling needed
|
||||
return resp.Choices[0].Message.Content
|
||||
}
|
||||
|
||||
// getThinkingModel returns the appropriate thinking model for the provider
|
||||
func (s *AIService) getThinkingModel() string {
|
||||
switch s.provider {
|
||||
case ProviderMistral:
|
||||
return os.Getenv("MISTRAL_MODEL_THINKING")
|
||||
case ProviderLongCat:
|
||||
return os.Getenv("LONGCAT_MODEL_THINKING")
|
||||
case ProviderGrok:
|
||||
return os.Getenv("GROK_MODEL_THINKING")
|
||||
case ProviderDeepSeek:
|
||||
return os.Getenv("DEEPSEEK_MODEL_THINKING")
|
||||
case ProviderOllama:
|
||||
return os.Getenv("OLLAMA_MODEL_THINKING")
|
||||
case ProviderOpenRouter:
|
||||
return os.Getenv("OPENROUTER_MODEL_THINKING")
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// callOpenRouter calls the OpenRouter API (OpenAI-compatible)
|
||||
func (s *AIService) callOpenRouter(req AIRequest) (*AIResponse, error) {
|
||||
apiKey := os.Getenv("OPENROUTER_API_KEY")
|
||||
baseURL := os.Getenv("OPENROUTER_BASE_URL")
|
||||
if baseURL == "" {
|
||||
baseURL = "https://openrouter.ai/api"
|
||||
}
|
||||
|
||||
model := os.Getenv("OPENROUTER_MODEL")
|
||||
if model == "" {
|
||||
model = "openrouter/auto"
|
||||
}
|
||||
|
||||
if req.Model == "" {
|
||||
req.Model = model
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequest("POST", baseURL+"/v1/chat/completions", strings.NewReader(string(jsonData)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
if apiKey != "" {
|
||||
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("OpenRouter API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var orResp AIResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&orResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &orResp, nil
|
||||
}
|
||||
|
||||
// callMistral calls Mistral AI API
|
||||
func (s *AIService) callMistral(req AIRequest) (*AIResponse, error) {
|
||||
apiKey := os.Getenv("MISTRAL_API_KEY")
|
||||
baseURL := "https://api.mistral.ai/v1"
|
||||
model := os.Getenv("MISTRAL_MODEL")
|
||||
if model == "" {
|
||||
model = "mistral-small-latest"
|
||||
}
|
||||
|
||||
if req.Model == "" {
|
||||
req.Model = model
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequest("POST", baseURL+"/chat/completions", strings.NewReader(string(jsonData)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(httpReq)
|
||||
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 AIResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&mistralResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &mistralResp, nil
|
||||
}
|
||||
|
||||
// callLongCat calls LongCat AI API
|
||||
func (s *AIService) callLongCat(req AIRequest) (*AIResponse, error) {
|
||||
apiKey := os.Getenv("LONGCAT_API_KEY")
|
||||
|
||||
// Determine format and endpoint
|
||||
format := os.Getenv("LONGCAT_FORMAT")
|
||||
if format == "" {
|
||||
format = "openai" // Default to OpenAI format
|
||||
}
|
||||
|
||||
var baseURL string
|
||||
switch format {
|
||||
case "openai":
|
||||
baseURL = "https://api.longcat.chat/openai"
|
||||
case "anthropic":
|
||||
baseURL = "https://api.longcat.chat/anthropic"
|
||||
default:
|
||||
baseURL = "https://api.longcat.chat/openai"
|
||||
}
|
||||
|
||||
model := os.Getenv("LONGCAT_MODEL")
|
||||
if model == "" {
|
||||
model = "LongCat-Flash-Chat"
|
||||
}
|
||||
|
||||
if req.Model == "" {
|
||||
req.Model = model
|
||||
}
|
||||
|
||||
var jsonBody []byte
|
||||
var httpReq *http.Request
|
||||
var err error
|
||||
|
||||
if format == "anthropic" {
|
||||
// Convert to Anthropic format
|
||||
anthropicReq := map[string]interface{}{
|
||||
"model": req.Model,
|
||||
"max_tokens": req.MaxTokens,
|
||||
"messages": req.Messages,
|
||||
}
|
||||
if req.Temperature > 0 {
|
||||
anthropicReq["temperature"] = req.Temperature
|
||||
}
|
||||
|
||||
jsonBody, err = json.Marshal(anthropicReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq, err = http.NewRequest("POST", baseURL+"/v1/messages", strings.NewReader(string(jsonBody)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
httpReq.Header.Set("anthropic-version", "2023-06-01")
|
||||
} else {
|
||||
// OpenAI format
|
||||
jsonBody, err = json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq, err = http.NewRequest("POST", baseURL+"/v1/chat/completions", strings.NewReader(string(jsonBody)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("LongCat API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var longcatResp AIResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&longcatResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &longcatResp, nil
|
||||
}
|
||||
|
||||
// callGrok calls Grok AI API
|
||||
func (s *AIService) callGrok(req AIRequest) (*AIResponse, error) {
|
||||
apiKey := os.Getenv("GROK_API_KEY")
|
||||
baseURL := os.Getenv("GROK_BASE_URL")
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.x.ai/v1"
|
||||
}
|
||||
|
||||
model := os.Getenv("GROK_MODEL")
|
||||
if model == "" {
|
||||
model = "grok-beta"
|
||||
}
|
||||
|
||||
if req.Model == "" {
|
||||
req.Model = model
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequest("POST", baseURL+"/chat/completions", strings.NewReader(string(jsonData)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("Grok API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var grokResp AIResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&grokResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &grokResp, nil
|
||||
}
|
||||
|
||||
// callDeepSeek calls DeepSeek API
|
||||
func (s *AIService) callDeepSeek(req AIRequest) (*AIResponse, error) {
|
||||
apiKey := os.Getenv("DEEPSEEK_API_KEY")
|
||||
baseURL := os.Getenv("DEEPSEEK_BASE_URL")
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.deepseek.com"
|
||||
}
|
||||
|
||||
model := os.Getenv("DEEPSEEK_MODEL")
|
||||
if model == "" {
|
||||
model = "deepseek-chat"
|
||||
}
|
||||
|
||||
if req.Model == "" {
|
||||
req.Model = model
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequest("POST", baseURL+"/chat/completions", strings.NewReader(string(jsonData)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("DeepSeek API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var deepseekResp AIResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&deepseekResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &deepseekResp, nil
|
||||
}
|
||||
|
||||
// callOllama calls Ollama API
|
||||
func (s *AIService) callOllama(req AIRequest) (*AIResponse, error) {
|
||||
baseURL := os.Getenv("OLLAMA_BASE_URL")
|
||||
if baseURL == "" {
|
||||
baseURL = "http://localhost:11434"
|
||||
}
|
||||
|
||||
model := os.Getenv("OLLAMA_MODEL")
|
||||
if model == "" {
|
||||
model = "llama3.1"
|
||||
}
|
||||
|
||||
if req.Model == "" {
|
||||
req.Model = model
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequest("POST", baseURL+"/api/chat", strings.NewReader(string(jsonData)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 60 * time.Second} // Ollama can be slower
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("Ollama API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var ollamaResp AIResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&ollamaResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ollamaResp, nil
|
||||
}
|
||||
|
||||
// SetProvider changes the AI provider
|
||||
func (s *AIService) SetProvider(provider AIProvider) {
|
||||
s.provider = provider
|
||||
}
|
||||
|
||||
// GetProvider returns the current AI provider
|
||||
func (s *AIService) GetProvider() AIProvider {
|
||||
return s.provider
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// WsEvent is a realtime event payload emitted by the messaging hub.
|
||||
type WsEvent struct {
|
||||
Type string `json:"type"`
|
||||
ConversationID uint `json:"conversation_id,omitempty"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
// MessagesWSClient represents one websocket connection.
|
||||
type MessagesWSClient struct {
|
||||
UserID uint
|
||||
Conn *websocket.Conn
|
||||
Send chan []byte
|
||||
Conversations map[uint]struct{}
|
||||
}
|
||||
|
||||
// MessagesHub coordinates room-based websocket fanout.
|
||||
type MessagesHub struct {
|
||||
mu sync.RWMutex
|
||||
conversationClients map[uint]map[*MessagesWSClient]struct{}
|
||||
clientConversations map[*MessagesWSClient]map[uint]struct{}
|
||||
}
|
||||
|
||||
var defaultMessagesHub = NewMessagesHub()
|
||||
|
||||
// GetMessagesHub returns the shared messaging websocket hub.
|
||||
func GetMessagesHub() *MessagesHub {
|
||||
return defaultMessagesHub
|
||||
}
|
||||
|
||||
// NewMessagesHub creates a new messages websocket hub.
|
||||
func NewMessagesHub() *MessagesHub {
|
||||
return &MessagesHub{
|
||||
conversationClients: make(map[uint]map[*MessagesWSClient]struct{}),
|
||||
clientConversations: make(map[*MessagesWSClient]map[uint]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// NewWSClient creates a ws client wrapper.
|
||||
func NewWSClient(userID uint, conn *websocket.Conn) *MessagesWSClient {
|
||||
return &MessagesWSClient{
|
||||
UserID: userID,
|
||||
Conn: conn,
|
||||
Send: make(chan []byte, 128),
|
||||
Conversations: make(map[uint]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// AddClientToConversation subscribes a client to a conversation room.
|
||||
func (h *MessagesHub) AddClientToConversation(client *MessagesWSClient, conversationID uint) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if _, exists := h.conversationClients[conversationID]; !exists {
|
||||
h.conversationClients[conversationID] = make(map[*MessagesWSClient]struct{})
|
||||
}
|
||||
h.conversationClients[conversationID][client] = struct{}{}
|
||||
|
||||
if _, exists := h.clientConversations[client]; !exists {
|
||||
h.clientConversations[client] = make(map[uint]struct{})
|
||||
}
|
||||
h.clientConversations[client][conversationID] = struct{}{}
|
||||
client.Conversations[conversationID] = struct{}{}
|
||||
}
|
||||
|
||||
// RemoveClientFromConversation unsubscribes a client from one room.
|
||||
func (h *MessagesHub) RemoveClientFromConversation(client *MessagesWSClient, conversationID uint) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if clients, exists := h.conversationClients[conversationID]; exists {
|
||||
delete(clients, client)
|
||||
if len(clients) == 0 {
|
||||
delete(h.conversationClients, conversationID)
|
||||
}
|
||||
}
|
||||
|
||||
if convs, exists := h.clientConversations[client]; exists {
|
||||
delete(convs, conversationID)
|
||||
if len(convs) == 0 {
|
||||
delete(h.clientConversations, client)
|
||||
}
|
||||
}
|
||||
delete(client.Conversations, conversationID)
|
||||
}
|
||||
|
||||
// RemoveClient fully unregisters a client from all rooms.
|
||||
func (h *MessagesHub) RemoveClient(client *MessagesWSClient) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if convs, exists := h.clientConversations[client]; exists {
|
||||
for convID := range convs {
|
||||
if clients, ok := h.conversationClients[convID]; ok {
|
||||
delete(clients, client)
|
||||
if len(clients) == 0 {
|
||||
delete(h.conversationClients, convID)
|
||||
}
|
||||
}
|
||||
}
|
||||
delete(h.clientConversations, client)
|
||||
}
|
||||
|
||||
close(client.Send)
|
||||
}
|
||||
|
||||
// Broadcast emits an event to all clients in one conversation room.
|
||||
func (h *MessagesHub) Broadcast(conversationID uint, eventType string, data interface{}) {
|
||||
event := WsEvent{
|
||||
Type: eventType,
|
||||
ConversationID: conversationID,
|
||||
Data: data,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
h.mu.RLock()
|
||||
clients := h.conversationClients[conversationID]
|
||||
h.mu.RUnlock()
|
||||
|
||||
for client := range clients {
|
||||
select {
|
||||
case client.Send <- raw:
|
||||
default:
|
||||
go h.RemoveClient(client)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SendToUser emits an event to one user in a conversation room.
|
||||
func (h *MessagesHub) SendToUser(conversationID, userID uint, eventType string, data interface{}) {
|
||||
event := WsEvent{
|
||||
Type: eventType,
|
||||
ConversationID: conversationID,
|
||||
Data: data,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
h.mu.RLock()
|
||||
clients := h.conversationClients[conversationID]
|
||||
h.mu.RUnlock()
|
||||
|
||||
for client := range clients {
|
||||
if client.UserID != userID {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case client.Send <- raw:
|
||||
default:
|
||||
go h.RemoveClient(client)
|
||||
}
|
||||
}
|
||||
}
|
||||
+64
-22
@@ -8,7 +8,6 @@ import { Bookmarks } from '@/pages/content/Bookmarks'
|
||||
import { Tasks } from '@/pages/productivity/Tasks'
|
||||
import { Files } from '@/pages/content/Files'
|
||||
import { Notes } from '@/pages/content/Notes'
|
||||
import Chat from '@/pages/communication/Chat'
|
||||
import { Settings } from '@/pages/settings/Settings'
|
||||
import { Login } from '@/pages/auth/Login'
|
||||
import { Youtube } from '@/pages/content/Youtube'
|
||||
@@ -23,11 +22,11 @@ import { LearningPaths } from '@/pages/content/LearningPaths'
|
||||
import { GitHub } from '@/pages/content/GitHub'
|
||||
import { TimeTracking } from '@/pages/productivity/TimeTracking'
|
||||
import { Calendar } from '@/pages/productivity/Calendar'
|
||||
import { WorkspaceSetup } from '@/pages/auth/WorkspaceSetup'
|
||||
import { AuthCallback } from '@/pages/auth/AuthCallback'
|
||||
import { AuthProvider, useAuth } from '@/lib/auth'
|
||||
import { Search } from '@/pages/content/Search'
|
||||
import { Analytics } from '@/pages/admin/Analytics'
|
||||
import { Messages } from '@/pages/communication/Messages'
|
||||
import { ShareTarget } from '@/pages/misc/ShareTarget'
|
||||
import BrowserExtensionSettings from '@/pages/settings/BrowserExtensionSettings'
|
||||
import { initializeDemoMode, clearDemoMode, isEnvDemoMode } from '@/lib/demo-mode'
|
||||
@@ -40,29 +39,78 @@ const initializeDarkMode = () => {
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
const user = localStorage.getItem('user') || localStorage.getItem('trackeep_user');
|
||||
|
||||
const root = document.documentElement;
|
||||
|
||||
root.style.removeProperty('--foreground');
|
||||
root.style.removeProperty('--colors-foreground');
|
||||
root.style.removeProperty('--background');
|
||||
root.style.removeProperty('--colors-background');
|
||||
root.style.removeProperty('--primary');
|
||||
root.style.removeProperty('--colors-primary');
|
||||
root.style.removeProperty('--muted');
|
||||
root.style.removeProperty('--colors-muted');
|
||||
root.style.removeProperty('--border');
|
||||
root.style.removeProperty('--colors-border');
|
||||
|
||||
if (user) {
|
||||
try {
|
||||
const userData = JSON.parse(user);
|
||||
// Prefer user's saved theme from profile, fallback to localStorage
|
||||
const userTheme = userData.theme || savedTheme;
|
||||
if (userTheme === 'dark') {
|
||||
document.documentElement.setAttribute('data-kb-theme', 'dark');
|
||||
root.setAttribute('data-kb-theme', 'dark');
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-kb-theme');
|
||||
root.removeAttribute('data-kb-theme');
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback to localStorage or dark mode if user data is invalid
|
||||
if (savedTheme === 'dark') {
|
||||
document.documentElement.setAttribute('data-kb-theme', 'dark');
|
||||
root.setAttribute('data-kb-theme', 'dark');
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-kb-theme');
|
||||
root.removeAttribute('data-kb-theme');
|
||||
}
|
||||
}
|
||||
} else if (savedTheme === 'dark') {
|
||||
document.documentElement.setAttribute('data-kb-theme', 'dark');
|
||||
root.setAttribute('data-kb-theme', 'dark');
|
||||
} else {
|
||||
// Default to dark mode
|
||||
document.documentElement.setAttribute('data-kb-theme', 'dark');
|
||||
root.setAttribute('data-kb-theme', 'dark');
|
||||
}
|
||||
|
||||
const savedColorScheme = localStorage.getItem('colorScheme');
|
||||
if (savedColorScheme && savedColorScheme !== 'default') {
|
||||
const schemeColors: Record<string, string> = {
|
||||
'ocean': '#0077be',
|
||||
'forest': '#228b22',
|
||||
'sunset': '#ff6b35',
|
||||
'purple': '#8b5cf6',
|
||||
};
|
||||
const primary = schemeColors[savedColorScheme];
|
||||
if (primary) {
|
||||
const hexToHsl = (hex: string) => {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
if (!result) return '0 0% 100%';
|
||||
let r = parseInt(result[1], 16) / 255;
|
||||
let g = parseInt(result[2], 16) / 255;
|
||||
let b = parseInt(result[3], 16) / 255;
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
let h = 0, s = 0, l = (max + min) / 2;
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
switch (max) {
|
||||
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
|
||||
case g: h = ((b - r) / d + 2) / 6; break;
|
||||
case b: h = ((r - g) / d + 4) / 6; break;
|
||||
}
|
||||
}
|
||||
return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`;
|
||||
};
|
||||
const hsl = hexToHsl(primary);
|
||||
root.style.setProperty('--primary', hsl);
|
||||
root.style.setProperty('--colors-primary', hsl);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -143,6 +191,13 @@ function App() {
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
)} />
|
||||
<Route path="/app/workspace-setup" component={() => (
|
||||
<ProtectedRoute>
|
||||
<Layout title="Workspace Setup">
|
||||
<WorkspaceSetup />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
)} />
|
||||
<Route path="/app/bookmarks" component={() => (
|
||||
<ProtectedRoute>
|
||||
<Layout title="Bookmarks">
|
||||
@@ -206,20 +261,7 @@ function App() {
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
)} />
|
||||
<Route path="/app/chat" component={() => (
|
||||
<ProtectedRoute>
|
||||
<Layout title="AI Chat" fullBleed>
|
||||
<Chat />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
)} />
|
||||
<Route path="/app/messages" component={() => (
|
||||
<ProtectedRoute>
|
||||
<Layout title="Messages" fullBleed>
|
||||
<Messages />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
)} />
|
||||
|
||||
<Route path="/app/members" component={() => (
|
||||
<ProtectedRoute>
|
||||
<Layout title="Members">
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import { createMemo, Show } from 'solid-js';
|
||||
|
||||
interface AIProviderIconProps {
|
||||
providerId: string;
|
||||
size?: string;
|
||||
class?: string;
|
||||
white?: boolean;
|
||||
}
|
||||
|
||||
const inlineSVGs: Record<string, string> = {
|
||||
openrouter: '<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenRouter</title><path d="M16.804 1.957l7.22 4.105v.087L16.73 10.21l.017-2.117-.821-.03c-1.059-.028-1.611.002-2.268.11-1.064.175-2.038.577-3.147 1.352L8.345 11.03c-.284.195-.495.336-.68.455l-.515.322-.397.234.385.23.53.338c.476.314 1.17.796 2.701 1.866 1.11.775 2.083 1.177 3.147 1.352l.3.045c.694.091 1.375.094 2.825.033l.022-2.159 7.22 4.105v.087L16.589 22l.014-1.862-.635.022c-1.386.042-2.137.002-3.138-.162-1.694-.28-3.26-.926-4.881-2.059l-2.158-1.5a21.997 21.997 0 00-.755-.498l-.467-.28a55.927 55.927 0 00-.76-.43C2.908 14.73.563 14.116 0 14.116V9.888l.14.004c.564-.007 2.91-.622 3.809-1.124l1.016-.58.438-.274c.428-.28 1.072-.726 2.686-1.853 1.621-1.133 3.186-1.78 4.881-2.059 1.152-.19 1.974-.213 3.814-.138l.02-1.907z"></path></svg>',
|
||||
ollama: '<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Ollama</title><path d="M7.905 1.09c.216.085.411.225.588.41.295.306.544.744.734 1.263.191.522.315 1.1.362 1.68a5.054 5.054 0 012.049-.636l.051-.004c.87-.07 1.73.087 2.48.474.101.053.2.11.297.17.05-.569.172-1.134.36-1.644.19-.52.439-.957.733-1.264a1.67 1.67 0 01.589-.41c.257-.1.53-.118.796-.042.401.114.745.368 1.016.737.248.337.434.769.561 1.287.23.934.27 2.163.115 3.645l.053.04.026.019c.757.576 1.284 1.397 1.563 2.35.435 1.487.216 3.155-.534 4.088l-.018.021.002.003c.417.762.67 1.567.724 2.4l.002.03c.064 1.065-.2 2.137-.814 3.19l-.007.01.01.024c.472 1.157.62 2.322.438 3.486l-.006.039a.651.651 0 01-.747.536.648.648 0 01-.54-.742c.167-1.033.01-2.069-.48-3.123a.643.643 0 01.04-.617l.004-.006c.604-.924.854-1.83.8-2.72-.046-.779-.325-1.544-.8-2.273a.644.644 0 01.18-.886l.009-.006c.243-.159.467-.565.58-1.12a4.229 4.229 0 00-.095-1.974c-.205-.7-.58-1.284-1.105-1.683-.595-.454-1.383-.673-2.38-.61a.653.653 0 01-.632-.371c-.314-.665-.772-1.141-1.343-1.436a3.288 3.288 0 00-1.772-.332c-1.245.099-2.343.801-2.67 1.686a.652.652 0 01-.61.425c-1.067.002-1.893.252-2.497.703-.522.39-.878.935-1.066 1.588a4.07 4.07 0 00-.068 1.886c.112.558.331 1.02.582 1.269l.008.007c.212.207.257.53.109.785-.36.622-.629 1.549-.673 2.44-.05 1.018.186 1.902.719 2.536l.016.019a.643.643 0 01.095.69c-.576 1.236-.753 2.252-.562 3.052a.652.652 0 01-1.269.298c-.243-1.018-.078-2.184.473-3.498l.014-.035-.008-.012a4.339 4.339 0 01-.598-1.309l-.005-.019a5.764 5.764 0 01-.177-1.785c.044-.91.278-1.842.622-2.59l.012-.026-.002-.002c-.293-.418-.51-.953-.63-1.545l-.005-.024a5.352 5.352 0 01.093-2.49c.262-.915.777-1.701 1.536-2.269.06-.045.123-.09.186-.132-.159-1.493-.119-2.73.112-3.67.127-.518.314-.95.562-1.287.27-.368.614-.622 1.015-.737.266-.076.54-.059.797.042zm4.116 9.09c.936 0 1.8.313 2.446.855.63.527 1.005 1.235 1.005 1.94 0 .888-.406 1.58-1.133 2.022-.62.38-1.416.507-2.3.381a3.822 3.822 0 01-1.416-.507c-.727-.442-1.133-1.134-1.133-2.022 0-.705.375-1.413 1.005-1.94.646-.542 1.51-.855 2.446-.855z"></path></svg>',
|
||||
grok: '<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Grok</title><path d="M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815"></path></svg>',
|
||||
};
|
||||
|
||||
const iconPaths: Record<string, string> = {
|
||||
mistral: '/assets/mistral-color.svg',
|
||||
longcat: '/assets/longcat-color.svg',
|
||||
deepseek: '/assets/deepseek-color.svg',
|
||||
};
|
||||
|
||||
const fallbackIcons: Record<string, string> = {
|
||||
mistral: 'M',
|
||||
longcat: 'C',
|
||||
grok: 'G',
|
||||
deepseek: 'D',
|
||||
ollama: 'O',
|
||||
openrouter: 'OR',
|
||||
};
|
||||
|
||||
export function AIProviderIcon(props: AIProviderIconProps) {
|
||||
const inlineSVG = createMemo(() => inlineSVGs[props.providerId]);
|
||||
const iconPath = createMemo(() => iconPaths[props.providerId]);
|
||||
const fallbackIcon = createMemo(() => fallbackIcons[props.providerId] || 'AI');
|
||||
|
||||
// Use inline SVG if available (for openrouter, ollama, grok)
|
||||
if (inlineSVG()) {
|
||||
return (
|
||||
<div
|
||||
class={props.class}
|
||||
style={{
|
||||
width: props.size || "1.5rem",
|
||||
height: props.size || "1.5rem",
|
||||
display: "flex",
|
||||
"align-items": "center",
|
||||
"justify-content": "center",
|
||||
color: props.white ? "white" : "currentColor"
|
||||
}}
|
||||
innerHTML={inlineSVG()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Use image for other providers
|
||||
return (
|
||||
<Show when={iconPath()} fallback={
|
||||
<span class={props.class} style={{
|
||||
"font-size": props.size || "1rem",
|
||||
color: props.white ? "white" : "currentColor"
|
||||
}}>
|
||||
{fallbackIcon()}
|
||||
</span>
|
||||
}>
|
||||
<img
|
||||
src={iconPath()}
|
||||
alt={`${props.providerId} icon`}
|
||||
class={props.class}
|
||||
style={{
|
||||
width: props.size || "1.5rem",
|
||||
height: props.size || "1.5rem",
|
||||
"object-fit": "contain",
|
||||
filter: props.white ? "brightness(0) invert(1)" : "none"
|
||||
}}
|
||||
onError={(e) => {
|
||||
// Fallback to emoji if SVG fails to load
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = 'none';
|
||||
if (target.nextElementSibling) {
|
||||
(target.nextElementSibling as HTMLElement).style.display = 'inline';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
class={props.class}
|
||||
style={{
|
||||
"font-size": props.size || "1.5rem",
|
||||
display: "none",
|
||||
color: props.white ? "white" : "currentColor"
|
||||
}}
|
||||
>
|
||||
{fallbackIcon()}
|
||||
</span>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
import { createSignal, For, Show } from 'solid-js'
|
||||
import { IconSend, IconX, IconBrain, IconUser, IconChevronDown } from '@tabler/icons-solidjs'
|
||||
import { AIProviderIcon } from '../AIProviderIcon'
|
||||
|
||||
interface AIChatPanelProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
interface Message {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
timestamp: Date
|
||||
}
|
||||
|
||||
interface AIModel {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
provider: string
|
||||
category: string
|
||||
iconId?: string
|
||||
}
|
||||
|
||||
export function AIChatPanel(props: AIChatPanelProps) {
|
||||
const [messages, setMessages] = createSignal<Message[]>([
|
||||
{
|
||||
id: '1',
|
||||
role: 'assistant',
|
||||
content: 'Hello! I\'m your AI assistant. How can I help you today?',
|
||||
timestamp: new Date()
|
||||
}
|
||||
])
|
||||
const [inputValue, setInputValue] = createSignal('')
|
||||
const [selectedModel, setSelectedModel] = createSignal('longcat-flash-chat')
|
||||
const [showModelPicker, setShowModelPicker] = createSignal(false)
|
||||
|
||||
const aiModels: AIModel[] = [
|
||||
{ id: 'longcat-flash-chat', name: 'LongCat Flash Chat', description: 'Fast and efficient', provider: 'longcat', category: 'fast', iconId: 'longcat' },
|
||||
{ id: 'mistral-standard', name: 'Mistral Standard', description: 'Mistral default model', provider: 'mistral', category: 'standard', iconId: 'mistral' },
|
||||
{ id: 'grok-standard', name: 'Grok Standard', description: 'Grok from X', provider: 'grok', category: 'standard', iconId: 'grok' },
|
||||
{ id: 'deepseek-chat', name: 'DeepSeek Chat', description: 'DeepSeek chat model', provider: 'deepseek', category: 'standard', iconId: 'deepseek' },
|
||||
{ id: 'ollama-local', name: 'Ollama Local', description: 'Local Ollama model', provider: 'ollama', category: 'local', iconId: 'ollama' },
|
||||
{ id: 'openrouter-auto', name: 'OpenRouter Auto', description: 'Router over many models', provider: 'openrouter', category: 'standard', iconId: 'openrouter' },
|
||||
]
|
||||
|
||||
const handleSendMessage = () => {
|
||||
const value = inputValue().trim()
|
||||
if (!value) return
|
||||
|
||||
const userMessage: Message = {
|
||||
id: Date.now().toString(),
|
||||
role: 'user',
|
||||
content: value,
|
||||
timestamp: new Date()
|
||||
}
|
||||
|
||||
setMessages(prev => [...prev, userMessage])
|
||||
setInputValue('')
|
||||
|
||||
// Simulate AI response
|
||||
setTimeout(() => {
|
||||
const aiMessage: Message = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: 'assistant',
|
||||
content: 'I understand your question. Let me help you with that...',
|
||||
timestamp: new Date()
|
||||
}
|
||||
setMessages(prev => [...prev, aiMessage])
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const handleKeyPress = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSendMessage()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Chat Panel */}
|
||||
<div class={`fixed right-0 top-0 h-full bg-card border-l border-border shadow-xl transition-transform duration-300 z-50 ${
|
||||
props.isOpen ? 'translate-x-0' : 'translate-x-full'
|
||||
}`} style="width: min(420px, 100vw); max-width: 100vw;">
|
||||
{/* Header */}
|
||||
<div class="flex items-center justify-between p-4 border-b border-border">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center justify-center p-2 rounded-lg bg-primary/10">
|
||||
<IconBrain class="size-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold">AI Assistant</h3>
|
||||
<p class="text-xs text-muted-foreground">Always here to help</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={props.onClose}
|
||||
class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-inherit hover:bg-accent/50 hover:text-accent-foreground h-8 w-8"
|
||||
>
|
||||
<IconX class="size-4 text-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div class="flex-1 overflow-y-auto p-4 space-y-4" style="height: calc(100vh - 200px); max-height: calc(100vh - 200px);">
|
||||
<For each={messages()}>
|
||||
{(message) => (
|
||||
<div class={`flex gap-3 ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
{message.role === 'assistant' && (
|
||||
<div class="flex items-center justify-center p-2 rounded-lg bg-gradient-to-br from-muted to-muted/90 border border-border/50 flex-shrink-0 shadow-sm">
|
||||
<IconBrain class="size-4 text-primary animate-pulse" />
|
||||
</div>
|
||||
)}
|
||||
<div class={`max-w-[280px] rounded-2xl p-3 shadow-sm transition-all duration-200 hover:shadow-md ${
|
||||
message.role === 'user'
|
||||
? 'bg-gradient-to-br from-primary to-primary/90 text-primary-foreground rounded-br-sm ml-auto'
|
||||
: 'bg-gradient-to-br from-muted to-muted/90 border border-border/50 rounded-bl-sm'
|
||||
}`}>
|
||||
<p class="text-sm leading-relaxed">{message.content}</p>
|
||||
<div class="flex items-center justify-between mt-2">
|
||||
<p class="text-xs opacity-70">
|
||||
{message.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</p>
|
||||
{message.role === 'user' && (
|
||||
<div class="w-1.5 h-1.5 bg-primary-foreground/50 rounded-full"></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{message.role === 'user' && (
|
||||
<div class="flex items-center justify-center p-2 rounded-lg bg-gradient-to-br from-primary to-primary/90 flex-shrink-0 shadow-sm">
|
||||
<IconUser class="size-4 text-primary-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div class="p-4 border-t border-border bg-card">
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={inputValue()}
|
||||
onInput={(e) => setInputValue(e.currentTarget.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="Type your message..."
|
||||
class="flex-1 h-10 w-full rounded-full border border-border/50 bg-background/95 backdrop-blur-sm px-4 py-2 text-sm shadow-sm transition-all duration-200 focus:shadow-md focus:border-primary/50 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/20 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSendMessage}
|
||||
disabled={!inputValue().trim()}
|
||||
class="inline-flex items-center justify-center rounded-full text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/20 disabled:pointer-events-none disabled:opacity-50 bg-gradient-to-r from-primary to-primary/90 text-primary-foreground shadow-sm hover:shadow-md hover:from-primary/90 hover:to-primary h-10 w-10 disabled:cursor-not-allowed"
|
||||
>
|
||||
<IconSend class="size-4 text-primary-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Model Picker at Bottom */}
|
||||
<div class="mt-3 pt-3 border-t border-border">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="relative">
|
||||
<button
|
||||
onClick={() => setShowModelPicker(!showModelPicker())}
|
||||
class="flex items-center gap-2 px-3 py-1.5 bg-muted hover:bg-muted/80 rounded-full text-xs transition-colors"
|
||||
>
|
||||
<AIProviderIcon
|
||||
providerId={aiModels.find(m => m.id === selectedModel())?.iconId || 'longcat'}
|
||||
size="1rem"
|
||||
class="rounded-full"
|
||||
/>
|
||||
<span class="text-muted-foreground">
|
||||
{aiModels.find(m => m.id === selectedModel())?.name?.split(' ')[0] || 'AI'}
|
||||
</span>
|
||||
<IconChevronDown class={`size-3 transition-transform ${showModelPicker() ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
<Show when={showModelPicker()}>
|
||||
<div class="absolute bottom-full left-0 mb-2 w-64 bg-gradient-to-b from-background to-background/95 backdrop-blur-sm border border-border/50 rounded-xl shadow-lg z-50 p-1 max-h-48 overflow-y-auto">
|
||||
<For each={aiModels}>
|
||||
{model => (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedModel(model.id)
|
||||
setShowModelPicker(false)
|
||||
}}
|
||||
class={`w-full text-left p-2 rounded-lg text-xs transition-all duration-200 ${
|
||||
selectedModel() === model.id
|
||||
? 'bg-gradient-to-r from-primary/10 to-primary/5 border border-primary/20'
|
||||
: 'hover:bg-muted/50'
|
||||
}`}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<AIProviderIcon
|
||||
providerId={model.iconId!}
|
||||
size="0.75rem"
|
||||
class="rounded-full flex-shrink-0"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium truncate">{model.name}</div>
|
||||
<div class="text-muted-foreground text-xs truncate">{model.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span>{aiModels.find(m => m.id === selectedModel())?.provider || 'LongCat'}</span>
|
||||
<a href="/app/settings#ai" class="text-primary hover:underline">
|
||||
AI settings
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
import { children, createSignal, onMount } from 'solid-js'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import { Header } from './Header'
|
||||
import { AIChatPanel } from './AIChatPanel'
|
||||
import { IconBrain } from '@tabler/icons-solidjs'
|
||||
import { isEnvDemoMode } from '@/lib/demo-mode'
|
||||
|
||||
export interface LayoutProps {
|
||||
@@ -14,7 +12,6 @@ export interface LayoutProps {
|
||||
|
||||
export function Layout(props: LayoutProps) {
|
||||
const resolved = children(() => props.children)
|
||||
const [isChatOpen, setIsChatOpen] = createSignal(false)
|
||||
const [isSidebarOpen, setIsSidebarOpen] = createSignal(true)
|
||||
|
||||
onMount(() => {
|
||||
@@ -25,7 +22,6 @@ export function Layout(props: LayoutProps) {
|
||||
setIsSidebarOpen(window.innerWidth >= 768)
|
||||
}
|
||||
|
||||
// Initialize dark mode from localStorage or system preference
|
||||
const savedTheme = localStorage.getItem('theme')
|
||||
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
|
||||
@@ -38,49 +34,38 @@ export function Layout(props: LayoutProps) {
|
||||
document.documentElement.removeAttribute('data-kb-theme')
|
||||
}
|
||||
|
||||
// Initialize color scheme from localStorage
|
||||
const savedColorScheme = localStorage.getItem('colorScheme');
|
||||
const savedCustomColors = localStorage.getItem('customColors');
|
||||
|
||||
if (savedColorScheme === 'custom' && savedCustomColors) {
|
||||
try {
|
||||
const colors = JSON.parse(savedCustomColors);
|
||||
|
||||
// Apply custom colors
|
||||
const hexToHsl = (hex: string) => {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
if (!result) return '0 0% 100%';
|
||||
|
||||
let r = parseInt(result[1], 16) / 255;
|
||||
let g = parseInt(result[2], 16) / 255;
|
||||
let b = parseInt(result[3], 16) / 255;
|
||||
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
let h = 0, s = 0, l = (max + min) / 2;
|
||||
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
|
||||
switch (max) {
|
||||
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
|
||||
case g: h = ((b - r) / d + 2) / 6; break;
|
||||
case b: h = ((r - g) / d + 4) / 6; break;
|
||||
}
|
||||
}
|
||||
|
||||
return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`;
|
||||
};
|
||||
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--primary', hexToHsl(colors.primary));
|
||||
root.style.setProperty('--background', hexToHsl(colors.background));
|
||||
root.style.setProperty('--foreground', hexToHsl(colors.foreground));
|
||||
root.style.setProperty('--muted', hexToHsl(colors.muted));
|
||||
root.style.setProperty('--border', colors.border);
|
||||
|
||||
// Also set as CSS custom properties for direct use
|
||||
root.style.setProperty('--colors-primary', hexToHsl(colors.primary));
|
||||
root.style.setProperty('--colors-background', hexToHsl(colors.background));
|
||||
root.style.setProperty('--colors-foreground', hexToHsl(colors.foreground));
|
||||
@@ -90,7 +75,6 @@ export function Layout(props: LayoutProps) {
|
||||
console.error('Failed to load custom colors:', e);
|
||||
}
|
||||
} else if (savedColorScheme) {
|
||||
// Apply predefined scheme
|
||||
const predefinedSchemes: Record<string, any> = {
|
||||
'default': { primary: '#5ab9ff', background: savedTheme === 'dark' ? '#1a1a1a' : '#ffffff', foreground: savedTheme === 'dark' ? '#ffffff' : '#000000', muted: savedTheme === 'dark' ? '#262727' : '#f5f5f5', border: '#262626' },
|
||||
'ocean': { primary: '#0077be', background: savedTheme === 'dark' ? '#001f3f' : '#e6f3ff', foreground: savedTheme === 'dark' ? '#ffffff' : '#000000', muted: savedTheme === 'dark' ? '#003366' : '#cce7ff', border: '#004080' },
|
||||
@@ -103,43 +87,34 @@ export function Layout(props: LayoutProps) {
|
||||
'cyan': { primary: '#06b6d4', background: savedTheme === 'dark' ? '#022c3a' : '#ecfeff', foreground: savedTheme === 'dark' ? '#ffffff' : '#000000', muted: savedTheme === 'dark' ? '#164e63' : '#cffafe', border: '#0891b2' },
|
||||
'indigo': { primary: '#6366f1', background: savedTheme === 'dark' ? '#1e1b4b' : '#eef2ff', foreground: savedTheme === 'dark' ? '#ffffff' : '#000000', muted: savedTheme === 'dark' ? '#312e81' : '#e0e7ff', border: '#4338ca' }
|
||||
};
|
||||
|
||||
const scheme = predefinedSchemes[savedColorScheme];
|
||||
if (scheme) {
|
||||
const hexToHsl = (hex: string) => {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
if (!result) return '0 0% 100%';
|
||||
|
||||
let r = parseInt(result[1], 16) / 255;
|
||||
let g = parseInt(result[2], 16) / 255;
|
||||
let b = parseInt(result[3], 16) / 255;
|
||||
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
let h = 0, s = 0, l = (max + min) / 2;
|
||||
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
|
||||
switch (max) {
|
||||
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
|
||||
case g: h = ((b - r) / d + 2) / 6; break;
|
||||
case b: h = ((r - g) / d + 4) / 6; break;
|
||||
}
|
||||
}
|
||||
|
||||
return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`;
|
||||
};
|
||||
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--primary', hexToHsl(scheme.primary));
|
||||
root.style.setProperty('--background', hexToHsl(scheme.background));
|
||||
root.style.setProperty('--foreground', hexToHsl(scheme.foreground));
|
||||
root.style.setProperty('--muted', hexToHsl(scheme.muted));
|
||||
root.style.setProperty('--border', scheme.border);
|
||||
|
||||
// Also set as CSS custom properties for direct use
|
||||
root.style.setProperty('--colors-primary', hexToHsl(scheme.primary));
|
||||
root.style.setProperty('--colors-background', hexToHsl(scheme.background));
|
||||
root.style.setProperty('--colors-foreground', hexToHsl(scheme.foreground));
|
||||
@@ -149,10 +124,6 @@ export function Layout(props: LayoutProps) {
|
||||
}
|
||||
})
|
||||
|
||||
const toggleChat = () => {
|
||||
setIsChatOpen(!isChatOpen())
|
||||
}
|
||||
|
||||
const toggleSidebar = () => {
|
||||
const nextValue = !isSidebarOpen()
|
||||
setIsSidebarOpen(nextValue)
|
||||
@@ -166,43 +137,19 @@ export function Layout(props: LayoutProps) {
|
||||
|
||||
return (
|
||||
<div class="min-h-screen font-sans text-sm font-400 bg-background text-foreground">
|
||||
|
||||
<div class="flex flex-row h-screen min-h-0 relative">
|
||||
{/* Mobile Sidebar Overlay */}
|
||||
{isSidebarOpen() && (
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 z-40 md:hidden"
|
||||
onClick={closeSidebar}
|
||||
/>
|
||||
<div class="fixed inset-0 bg-black/50 z-40 md:hidden" onClick={closeSidebar} />
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<Sidebar isOpen={isSidebarOpen()} onClose={closeSidebar} />
|
||||
|
||||
{/* Main Content */}
|
||||
<div class="flex-1 min-h-0 flex flex-col">
|
||||
{/* Header */}
|
||||
{!props.fullBleed && <Header title={props.title} onMenuClick={toggleSidebar} />}
|
||||
|
||||
{/* Page Content */}
|
||||
<main class={`flex-1 ${props.fullBleed ? 'overflow-hidden' : 'overflow-auto w-full'}`}>
|
||||
<div class={props.fullBleed ? "h-full" : "p-2 max-w-7xl mx-auto"}>
|
||||
{resolved()}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Floating AI Button */}
|
||||
<button
|
||||
onClick={toggleChat}
|
||||
class="fixed bottom-6 right-8 z-40 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg hover:bg-primary/90 transition-all duration-200 hover:scale-110 w-14 h-14"
|
||||
title="AI Assistant"
|
||||
>
|
||||
<IconBrain class="size-6 text-primary-foreground" />
|
||||
</button>
|
||||
|
||||
{/* AI Chat Panel */}
|
||||
<AIChatPanel isOpen={isChatOpen()} onClose={() => setIsChatOpen(false)} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -12,16 +12,21 @@ import {
|
||||
IconChevronDown,
|
||||
IconTrash,
|
||||
IconUsers,
|
||||
IconBrain,
|
||||
IconSchool,
|
||||
IconChartLine,
|
||||
IconBrandGithub,
|
||||
IconClock,
|
||||
IconCalendar,
|
||||
IconMessageCircle,
|
||||
IconLogout,
|
||||
IconBuilding,
|
||||
IconPlus
|
||||
IconPlus,
|
||||
IconWorld,
|
||||
IconLock,
|
||||
IconCheck,
|
||||
IconStar,
|
||||
IconHeart,
|
||||
IconBriefcase,
|
||||
IconEdit
|
||||
} from '@tabler/icons-solidjs'
|
||||
import { Input } from '../ui/Input'
|
||||
import { Button } from '../ui/Button'
|
||||
@@ -39,13 +44,11 @@ const navigation = [
|
||||
{ name: 'Calendar', href: '/app/calendar', icon: IconCalendar },
|
||||
{ name: 'Files', href: '/app/files', icon: IconFolder },
|
||||
{ name: 'Notes', href: '/app/notes', icon: IconNotebook },
|
||||
{ name: 'Messages', href: '/app/messages', icon: IconMessageCircle },
|
||||
{ name: 'YouTube', href: '/app/youtube', icon: IconVideo },
|
||||
{ name: 'Members', href: '/app/members', icon: IconUsers },
|
||||
{ name: 'Learning', href: '/app/learning-paths', icon: IconSchool },
|
||||
{ name: 'Stats', href: '/app/stats', icon: IconChartLine },
|
||||
{ name: 'GitHub', href: '/app/github', icon: IconBrandGithub },
|
||||
{ name: 'AI Assistant', href: '/app/chat', icon: IconBrain },
|
||||
]
|
||||
|
||||
const API_BASE_URL = getApiV1BaseUrl()
|
||||
@@ -55,13 +58,25 @@ interface WorkspaceOption {
|
||||
id: string
|
||||
name: string
|
||||
icon: typeof IconFileText
|
||||
iconId?: string
|
||||
description?: string
|
||||
is_public?: boolean
|
||||
}
|
||||
|
||||
const getWorkspaceIcon = (name: string) => {
|
||||
const lower = name.toLowerCase()
|
||||
if (lower.includes('team')) return IconUsers
|
||||
if (lower.includes('personal')) return IconBuilding
|
||||
return IconFileText
|
||||
const WORKSPACE_ICONS = [
|
||||
{ id: 'building', icon: IconBuilding },
|
||||
{ id: 'world', icon: IconWorld },
|
||||
{ id: 'lock', icon: IconLock },
|
||||
{ id: 'check', icon: IconCheck },
|
||||
{ id: 'star', icon: IconStar },
|
||||
{ id: 'heart', icon: IconHeart },
|
||||
{ id: 'home', icon: IconHome },
|
||||
{ id: 'briefcase', icon: IconBriefcase },
|
||||
]
|
||||
|
||||
const getWorkspaceIcon = (iconId: string) => {
|
||||
const found = WORKSPACE_ICONS.find((i) => i.id === iconId)
|
||||
return found ? found.icon : IconBuilding
|
||||
}
|
||||
|
||||
const getAuthToken = () => localStorage.getItem('trackeep_token') || localStorage.getItem('token') || ''
|
||||
@@ -85,6 +100,14 @@ export function Sidebar(props: SidebarProps) {
|
||||
const [workspaceIsPublic, setWorkspaceIsPublic] = createSignal(false)
|
||||
const [isCreatingWorkspace, setIsCreatingWorkspace] = createSignal(false)
|
||||
const [createWorkspaceError, setCreateWorkspaceError] = createSignal('')
|
||||
const [isEditWorkspaceModalOpen, setIsEditWorkspaceModalOpen] = createSignal(false)
|
||||
const [editingWorkspace, setEditingWorkspace] = createSignal<WorkspaceOption | null>(null)
|
||||
const [editWorkspaceName, setEditWorkspaceName] = createSignal('')
|
||||
const [editWorkspaceDescription, setEditWorkspaceDescription] = createSignal('')
|
||||
const [editWorkspaceIsPublic, setEditWorkspaceIsPublic] = createSignal(false)
|
||||
const [editWorkspaceIcon, setEditWorkspaceIcon] = createSignal('building')
|
||||
const [isSavingWorkspace, setIsSavingWorkspace] = createSignal(false)
|
||||
const [saveWorkspaceError, setSaveWorkspaceError] = createSignal('')
|
||||
|
||||
const selectedWorkspace = () => {
|
||||
const list = workspaces()
|
||||
@@ -140,48 +163,27 @@ export function Sidebar(props: SidebarProps) {
|
||||
setIsWorkspaceDropdownOpen(!isWorkspaceDropdownOpen())
|
||||
}
|
||||
|
||||
const normalizeWorkspace = (team: { id?: number | string; name?: string }): WorkspaceOption => {
|
||||
const normalizeWorkspace = (team: { id?: number | string; name?: string; description?: string; is_public?: boolean; icon_id?: string }): WorkspaceOption => {
|
||||
const name = team.name?.trim() || DEFAULT_WORKSPACE_NAME
|
||||
const iconId = team.icon_id || 'building'
|
||||
return {
|
||||
id: String(team.id ?? `workspace-${Date.now()}`),
|
||||
name,
|
||||
icon: getWorkspaceIcon(name),
|
||||
icon: getWorkspaceIcon(iconId),
|
||||
iconId,
|
||||
description: team.description,
|
||||
is_public: team.is_public,
|
||||
}
|
||||
}
|
||||
|
||||
const createDefaultWorkspace = async (token: string): Promise<WorkspaceOption | null> => {
|
||||
const response = await fetch(`${API_BASE_URL}/teams`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: DEFAULT_WORKSPACE_NAME,
|
||||
description: 'Default workspace',
|
||||
is_public: false,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return null
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
if (!data?.team) {
|
||||
return null
|
||||
}
|
||||
|
||||
return normalizeWorkspace(data.team)
|
||||
}
|
||||
|
||||
const loadWorkspaces = async () => {
|
||||
const token = getAuthToken()
|
||||
if (!token) {
|
||||
const fallbackWorkspace = {
|
||||
id: 'local-default',
|
||||
name: DEFAULT_WORKSPACE_NAME,
|
||||
icon: IconFileText,
|
||||
icon: IconBuilding,
|
||||
iconId: 'building',
|
||||
}
|
||||
setWorkspaces([fallbackWorkspace])
|
||||
setSelectedWorkspaceId(fallbackWorkspace.id)
|
||||
@@ -204,20 +206,11 @@ export function Sidebar(props: SidebarProps) {
|
||||
}
|
||||
|
||||
if (mappedWorkspaces.length === 0) {
|
||||
const created = await createDefaultWorkspace(token)
|
||||
if (created) {
|
||||
mappedWorkspaces = [created]
|
||||
const currentPath = window.location.pathname
|
||||
if (currentPath === '/app' || currentPath === '/app/') {
|
||||
window.location.href = '/app/workspace-setup'
|
||||
}
|
||||
}
|
||||
|
||||
if (mappedWorkspaces.length === 0) {
|
||||
mappedWorkspaces = [
|
||||
{
|
||||
id: 'local-default',
|
||||
name: DEFAULT_WORKSPACE_NAME,
|
||||
icon: IconFileText,
|
||||
},
|
||||
]
|
||||
return
|
||||
}
|
||||
|
||||
setWorkspaces(mappedWorkspaces)
|
||||
@@ -233,7 +226,8 @@ export function Sidebar(props: SidebarProps) {
|
||||
const fallbackWorkspace = {
|
||||
id: 'local-default',
|
||||
name: DEFAULT_WORKSPACE_NAME,
|
||||
icon: IconFileText,
|
||||
icon: IconBuilding,
|
||||
iconId: 'building',
|
||||
}
|
||||
setWorkspaces([fallbackWorkspace])
|
||||
setSelectedWorkspaceId(fallbackWorkspace.id)
|
||||
@@ -258,7 +252,8 @@ export function Sidebar(props: SidebarProps) {
|
||||
const localWorkspace = {
|
||||
id: `local-${Date.now()}`,
|
||||
name: trimmed,
|
||||
icon: getWorkspaceIcon(trimmed),
|
||||
icon: getWorkspaceIcon('building'),
|
||||
iconId: 'building',
|
||||
}
|
||||
setWorkspaces((prev) => [localWorkspace, ...prev])
|
||||
handleWorkspaceSelect(localWorkspace)
|
||||
@@ -307,6 +302,87 @@ export function Sidebar(props: SidebarProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const openEditWorkspaceModal = (workspace: WorkspaceOption) => {
|
||||
setEditingWorkspace(workspace)
|
||||
setEditWorkspaceName(workspace.name)
|
||||
setEditWorkspaceDescription(workspace.description || '')
|
||||
setEditWorkspaceIsPublic(workspace.is_public || false)
|
||||
setEditWorkspaceIcon(workspace.iconId || 'building')
|
||||
setSaveWorkspaceError('')
|
||||
setIsEditWorkspaceModalOpen(true)
|
||||
setIsWorkspaceDropdownOpen(false)
|
||||
}
|
||||
|
||||
const closeEditWorkspaceModal = () => {
|
||||
if (isSavingWorkspace()) return
|
||||
setIsEditWorkspaceModalOpen(false)
|
||||
setEditingWorkspace(null)
|
||||
}
|
||||
|
||||
const handleSaveWorkspace = async () => {
|
||||
const workspace = editingWorkspace()
|
||||
if (!workspace) return
|
||||
const trimmed = editWorkspaceName().trim()
|
||||
if (!trimmed) {
|
||||
setSaveWorkspaceError('Workspace name required')
|
||||
return
|
||||
}
|
||||
setSaveWorkspaceError('')
|
||||
setIsSavingWorkspace(true)
|
||||
const token = getAuthToken()
|
||||
if (!token) {
|
||||
setWorkspaces((prev) =>
|
||||
prev.map((w) =>
|
||||
w.id === workspace.id
|
||||
? { ...w, name: trimmed, description: editWorkspaceDescription(), is_public: editWorkspaceIsPublic(), iconId: editWorkspaceIcon(), icon: getWorkspaceIcon(editWorkspaceIcon()) }
|
||||
: w
|
||||
)
|
||||
)
|
||||
setIsEditWorkspaceModalOpen(false)
|
||||
setIsSavingWorkspace(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/teams/${workspace.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: trimmed,
|
||||
description: editWorkspaceDescription().trim(),
|
||||
is_public: editWorkspaceIsPublic(),
|
||||
}),
|
||||
})
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
throw new Error(data?.error || 'Failed to save workspace')
|
||||
}
|
||||
setWorkspaces((prev) =>
|
||||
prev.map((w) =>
|
||||
w.id === workspace.id
|
||||
? { ...w, name: trimmed, description: editWorkspaceDescription(), is_public: editWorkspaceIsPublic(), iconId: editWorkspaceIcon(), icon: getWorkspaceIcon(editWorkspaceIcon()) }
|
||||
: w
|
||||
)
|
||||
)
|
||||
if (selectedWorkspaceId() === workspace.id) {
|
||||
persistSelectedWorkspace({
|
||||
...workspace,
|
||||
name: trimmed,
|
||||
icon: getWorkspaceIcon(editWorkspaceIcon()),
|
||||
iconId: editWorkspaceIcon()
|
||||
})
|
||||
}
|
||||
setIsEditWorkspaceModalOpen(false)
|
||||
} catch (error) {
|
||||
setSaveWorkspaceError(error instanceof Error ? error.message : 'Failed to save workspace')
|
||||
} finally {
|
||||
setIsSavingWorkspace(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
onMount(() => {
|
||||
void loadWorkspaces()
|
||||
@@ -375,22 +451,31 @@ export function Sidebar(props: SidebarProps) {
|
||||
<div class="p-1" role="listbox">
|
||||
<For each={workspaces()}>
|
||||
{(workspace) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleWorkspaceSelect(workspace)}
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-sm rounded-sm hover:bg-accent/50 transition-colors focus:bg-accent/50 focus:outline-none"
|
||||
role="option"
|
||||
classList={{ "bg-accent/30": workspace.id === selectedWorkspace().id }}
|
||||
>
|
||||
{(() => {
|
||||
const Icon = workspace.icon
|
||||
return <Icon class="size-4 text-muted-foreground" />
|
||||
})()}
|
||||
<span class="flex-1 text-left truncate">{workspace.name}</span>
|
||||
<Show when={workspace.id === selectedWorkspace().id}>
|
||||
<div class="w-2 h-2 bg-primary rounded-full"></div>
|
||||
</Show>
|
||||
</button>
|
||||
<div class="flex items-center gap-1 px-2 py-1 rounded-sm hover:bg-accent/50 transition-colors group" classList={{ "bg-accent/30": workspace.id === selectedWorkspace().id }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleWorkspaceSelect(workspace)}
|
||||
class="flex flex-1 items-center gap-2 text-sm focus:outline-none"
|
||||
role="option"
|
||||
>
|
||||
{(() => {
|
||||
const Icon = workspace.icon
|
||||
return <Icon class="size-4 text-muted-foreground" />
|
||||
})()}
|
||||
<span class="text-left truncate">{workspace.name}</span>
|
||||
<Show when={workspace.id === selectedWorkspace().id}>
|
||||
<div class="w-2 h-2 bg-primary rounded-full"></div>
|
||||
</Show>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEditWorkspaceModal(workspace)}
|
||||
class="p-1 rounded opacity-0 group-hover:opacity-100 hover:bg-accent/50 transition-opacity"
|
||||
title="Edit workspace"
|
||||
>
|
||||
<IconEdit class="size-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<div class="border-t border-border mt-1 pt-1">
|
||||
@@ -596,6 +681,102 @@ export function Sidebar(props: SidebarProps) {
|
||||
</>
|
||||
</ModalPortal>
|
||||
</Show>
|
||||
|
||||
<Show when={isEditWorkspaceModalOpen()}>
|
||||
<ModalPortal>
|
||||
<>
|
||||
<div
|
||||
class="fixed inset-0 z-[90] bg-black/50"
|
||||
onClick={closeEditWorkspaceModal}
|
||||
/>
|
||||
<div class="fixed top-1/2 left-1/2 z-[100] w-full max-w-md -translate-x-1/2 -translate-y-1/2 px-4">
|
||||
<div class="rounded-lg border border-border bg-card shadow-xl">
|
||||
<div class="border-b border-border p-5">
|
||||
<h3 class="text-lg font-semibold text-foreground">Edit Workspace</h3>
|
||||
<p class="mt-1 text-sm text-muted-foreground">Update workspace details and icon.</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 p-5">
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-sm font-medium text-foreground">Name</label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Workspace name"
|
||||
value={editWorkspaceName()}
|
||||
onInput={(event) => setEditWorkspaceName((event.currentTarget as HTMLInputElement).value)}
|
||||
disabled={isSavingWorkspace()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-sm font-medium text-foreground">Description</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
class="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder="Optional description"
|
||||
value={editWorkspaceDescription()}
|
||||
onInput={(event) => setEditWorkspaceDescription((event.currentTarget as HTMLTextAreaElement).value)}
|
||||
disabled={isSavingWorkspace()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-sm font-medium text-foreground mb-2 block">Icon</label>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
{WORKSPACE_ICONS.map((item) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditWorkspaceIcon(item.id)}
|
||||
class={`p-2 rounded-lg border transition-colors ${
|
||||
editWorkspaceIcon() === item.id
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-border hover:border-primary/50'
|
||||
}`}
|
||||
disabled={isSavingWorkspace()}
|
||||
>
|
||||
<Icon class="size-5" />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between rounded-md border border-border bg-background px-3 py-2">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Public workspace</p>
|
||||
<p class="text-xs text-muted-foreground">Allow all members to discover this workspace.</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={editWorkspaceIsPublic()}
|
||||
onCheckedChange={setEditWorkspaceIsPublic}
|
||||
disabled={isSavingWorkspace()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Show when={saveWorkspaceError()}>
|
||||
<p class="text-sm text-destructive">{saveWorkspaceError()}</p>
|
||||
</Show>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={closeEditWorkspaceModal}
|
||||
disabled={isSavingWorkspace()}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => void handleSaveWorkspace()} disabled={isSavingWorkspace()}>
|
||||
{isSavingWorkspace() ? 'Saving...' : 'Save Workspace'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</ModalPortal>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,374 +0,0 @@
|
||||
import { createSignal, For, Show } from 'solid-js';
|
||||
import { IconSearch, IconExternalLink, IconLoader2, IconBookmark } from '@tabler/icons-solidjs';
|
||||
import { type BraveSearchResult } from '@/lib/brave-search';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { isEnvDemoMode } from '@/lib/demo-mode';
|
||||
import { getApiBaseUrl } from '@/lib/credentials';
|
||||
|
||||
export const BrowserSearch = () => {
|
||||
const [searchQuery, setSearchQuery] = createSignal('');
|
||||
const [searchResults, setSearchResults] = createSignal<BraveSearchResult[]>([]);
|
||||
const [isLoading, setIsLoading] = createSignal(false);
|
||||
const [error, setError] = createSignal('');
|
||||
const [hasSearched, setHasSearched] = createSignal(false);
|
||||
const [searchType, setSearchType] = createSignal<'web' | 'news'>('web');
|
||||
|
||||
// Add debouncing and request cancellation
|
||||
let searchTimeout: number | undefined;
|
||||
let currentRequestId: number = 0;
|
||||
|
||||
// Check if we're in demo mode
|
||||
const isDemo = () => {
|
||||
return isEnvDemoMode();
|
||||
};
|
||||
|
||||
const handleSearch = async () => {
|
||||
const query = searchQuery().trim();
|
||||
if (!query || isLoading()) return;
|
||||
|
||||
// Cancel any existing timeout
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout);
|
||||
}
|
||||
|
||||
// Increment request ID for cancellation
|
||||
const requestId = ++currentRequestId;
|
||||
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
setHasSearched(true);
|
||||
|
||||
try {
|
||||
const isDemoMode = isDemo();
|
||||
|
||||
// Always use backend API for search to avoid CORS issues
|
||||
const API_BASE_URL = getApiBaseUrl();
|
||||
const token = localStorage.getItem('token') ||
|
||||
localStorage.getItem('auth_token') ||
|
||||
localStorage.getItem('trackeep_token');
|
||||
const endpoint = searchType() === 'news' ? '/api/v1/search/news' : '/api/v1/search/web';
|
||||
|
||||
try {
|
||||
console.log(`Using backend search API: ${API_BASE_URL}${endpoint}`);
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': token ? `Bearer ${token}` : '',
|
||||
},
|
||||
body: JSON.stringify({ query, count: 8 }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// Check if this request is still current
|
||||
if (requestId === currentRequestId && data.results && data.results.length > 0) {
|
||||
setSearchResults(data.results);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
console.warn('Backend search returned error:', response.status, response.statusText);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Backend search failed:', err);
|
||||
}
|
||||
|
||||
// In demo mode or as fallback, use the demo mode API interceptor
|
||||
if (isDemoMode) {
|
||||
console.log('Demo mode detected, using demo API interceptor...');
|
||||
const API_BASE_URL = getApiBaseUrl();
|
||||
const endpoint = searchType() === 'news' ? '/api/v1/search/news' : '/api/v1/search/web';
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ query, count: 8 }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// Check if this request is still current
|
||||
if (requestId === currentRequestId) {
|
||||
// Handle demo mode response format
|
||||
const results = data.web?.results || data.news?.results || data.mixed?.results || data.results || [];
|
||||
if (results.length > 0) {
|
||||
setSearchResults(results);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
console.warn('Demo API failed, falling back to mock results...');
|
||||
}
|
||||
|
||||
// If all APIs fail or return no results, show appropriate message
|
||||
throw new Error('No search results available');
|
||||
|
||||
} catch (err) {
|
||||
console.error('Search failed:', err);
|
||||
|
||||
// Only show demo data if all APIs fail
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
||||
const apiFailed = errorMessage.includes('API') || errorMessage.includes('fetch') || errorMessage.includes('No search results');
|
||||
|
||||
if (apiFailed) {
|
||||
console.warn('All search APIs failed, showing demo results:', errorMessage);
|
||||
// Check if this request is still current
|
||||
if (requestId === currentRequestId) {
|
||||
const mockResults: BraveSearchResult[] = [
|
||||
{
|
||||
title: `${query} - Search Result 1`,
|
||||
url: `https://example.com/${query.toLowerCase().replace(/\s+/g, '-')}`,
|
||||
description: `This is a mock search result for "${query}" demonstrating the search functionality in demo mode.`,
|
||||
published_date: new Date().toISOString().split('T')[0],
|
||||
language: 'English'
|
||||
},
|
||||
{
|
||||
title: `${query} - Search Result 2`,
|
||||
url: `https://demo-site.com/${query.toLowerCase().replace(/\s+/g, '-')}`,
|
||||
description: `Another mock search result for "${query}" showing how the search interface works in demo mode.`,
|
||||
published_date: new Date().toISOString().split('T')[0],
|
||||
language: 'English'
|
||||
}
|
||||
];
|
||||
setSearchResults(mockResults);
|
||||
}
|
||||
} else {
|
||||
// Check if this request is still current before setting error
|
||||
if (requestId === currentRequestId) {
|
||||
setError('Search temporarily unavailable. Please try again later.');
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// Only update loading state if this is still the current request
|
||||
if (requestId === currentRequestId) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSearch();
|
||||
}
|
||||
};
|
||||
|
||||
const handleInput = (e: InputEvent) => {
|
||||
const target = e.currentTarget as HTMLInputElement;
|
||||
if (target) {
|
||||
setSearchQuery(target.value);
|
||||
}
|
||||
};
|
||||
|
||||
const openResult = (url: string) => {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
const bookmarkResult = async (result: BraveSearchResult) => {
|
||||
// If in demo mode, just show success message
|
||||
if (isDemo()) {
|
||||
// In demo mode, just show success without actual API call
|
||||
console.log('Demo mode: Bookmark created for', result.title);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const API_BASE_URL = getApiBaseUrl();
|
||||
const bookmarkData = {
|
||||
title: result.title,
|
||||
url: result.url,
|
||||
description: result.description,
|
||||
tags: ['web-search', 'browser-search']
|
||||
};
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/bookmarks`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': localStorage.getItem('token') ? `Bearer ${localStorage.getItem('token')}` : '',
|
||||
},
|
||||
body: JSON.stringify(bookmarkData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to create bookmark');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to bookmark search result:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
{/* Search Content */}
|
||||
<div class="space-y-6">
|
||||
{/* Search Bar */}
|
||||
<Card class="p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<span class="text-xs text-muted-foreground uppercase tracking-wide">Search type</span>
|
||||
<div class="inline-flex items-center gap-1 rounded-md bg-muted p-1">
|
||||
<Button
|
||||
variant={searchType() === 'web' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
class="h-7 px-3 text-xs"
|
||||
onClick={() => setSearchType('web')}
|
||||
>
|
||||
Web
|
||||
</Button>
|
||||
<Button
|
||||
variant={searchType() === 'news' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
class="h-7 px-3 text-xs"
|
||||
onClick={() => setSearchType('news')}
|
||||
>
|
||||
News
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-4">
|
||||
<div class="flex-1">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={searchType() === 'news' ? 'Search news...' : 'Search the web...'}
|
||||
value={searchQuery()}
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
class="text-base"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSearch}
|
||||
disabled={isLoading() || !searchQuery().trim()}
|
||||
size="lg"
|
||||
class="px-8"
|
||||
>
|
||||
{isLoading() ? (
|
||||
<span class="flex items-center gap-2">
|
||||
<IconLoader2 class="w-4 h-4 animate-spin" />
|
||||
Searching...
|
||||
</span>
|
||||
) : (
|
||||
<span class="flex items-center gap-2">
|
||||
<IconSearch class="w-4 h-4" />
|
||||
Search
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Error Message */}
|
||||
<Show when={error()}>
|
||||
<Card class="p-4 border-red-500/20 bg-red-500/5">
|
||||
<p class="text-red-400 text-sm">{error()}</p>
|
||||
</Card>
|
||||
</Show>
|
||||
|
||||
{/* Search Results */}
|
||||
<Show when={hasSearched() && !isLoading()}>
|
||||
<div class="space-y-4">
|
||||
<Show when={searchResults().length > 0}>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
Found {searchResults().length} results
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<For each={searchResults()}>
|
||||
{(result) => (
|
||||
<Card class="p-6 hover:bg-accent/50 transition-colors cursor-pointer group">
|
||||
<div class="space-y-2">
|
||||
{/* URL */}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-primary truncate">
|
||||
{result.url}
|
||||
</span>
|
||||
<IconExternalLink class="w-3.5 h-3.5 ml-1 text-primary flex-shrink-0" />
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h3
|
||||
class="text-lg font-semibold hover:text-primary transition-colors"
|
||||
onClick={() => openResult(result.url)}
|
||||
>
|
||||
{result.title}
|
||||
</h3>
|
||||
|
||||
{/* Description */}
|
||||
<p class="text-muted-foreground text-sm line-clamp-2">
|
||||
{result.description}
|
||||
</p>
|
||||
|
||||
{/* Meta info */}
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<Show when={result.published_date}>
|
||||
<span>{result.published_date}</span>
|
||||
</Show>
|
||||
<Show when={result.language}>
|
||||
<span>Language: {result.language}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => bookmarkResult(result)}
|
||||
class="mt-2"
|
||||
>
|
||||
<IconBookmark class="w-3 h-3 mr-1" />
|
||||
Bookmark
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openResult(result.url)}
|
||||
class="mt-2"
|
||||
>
|
||||
<IconExternalLink class="w-3.5 h-3.5 mr-1" />
|
||||
Visit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={searchResults().length === 0 && !error()}>
|
||||
<Card class="p-12 text-center">
|
||||
<div class="max-w-md mx-auto">
|
||||
<IconSearch class="w-12 h-12 text-muted-foreground mx-auto mb-4" />
|
||||
<h3 class="text-lg font-semibold mb-2">No results found</h3>
|
||||
<p class="text-muted-foreground">
|
||||
Try searching with different keywords or check your spelling.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Initial State */}
|
||||
<Show when={!hasSearched()}>
|
||||
<Card class="p-12 text-center">
|
||||
<div class="max-w-md mx-auto">
|
||||
<IconSearch class="w-16 h-16 text-primary mx-auto mb-4" />
|
||||
<h3 class="text-lg font-semibold mb-2">Search the web using Brave Search API</h3>
|
||||
<p class="text-muted-foreground">
|
||||
Enter keywords above to search the web and bookmark results.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { ConfirmModal } from '@/components/ui/ConfirmModal';
|
||||
|
||||
interface SavedSearch {
|
||||
id: number;
|
||||
@@ -47,6 +48,8 @@ export const SavedSearches = () => {
|
||||
const [loading, setLoading] = createSignal(false);
|
||||
const [showCreateModal, setShowCreateModal] = createSignal(false);
|
||||
const [editingSearch, setEditingSearch] = createSignal<SavedSearch | null>(null);
|
||||
const [showDeleteModal, setShowDeleteModal] = createSignal(false);
|
||||
const [searchToDelete, setSearchToDelete] = createSignal<number | null>(null);
|
||||
const [formData, setFormData] = createSignal<SavedSearchFormData>({
|
||||
name: '',
|
||||
query: '',
|
||||
@@ -136,9 +139,13 @@ export const SavedSearches = () => {
|
||||
|
||||
// Delete saved search
|
||||
const deleteSavedSearch = async (id: number) => {
|
||||
if (!confirm('Are you sure you want to delete this saved search?')) {
|
||||
return;
|
||||
}
|
||||
setSearchToDelete(id);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const confirmDeleteSavedSearch = async () => {
|
||||
const id = searchToDelete();
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
@@ -151,6 +158,8 @@ export const SavedSearches = () => {
|
||||
|
||||
if (response.ok) {
|
||||
loadSavedSearches();
|
||||
setShowDeleteModal(false);
|
||||
setSearchToDelete(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete saved search:', error);
|
||||
@@ -479,6 +488,19 @@ export const SavedSearches = () => {
|
||||
</Card>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={showDeleteModal()}
|
||||
onClose={() => {
|
||||
setShowDeleteModal(false);
|
||||
setSearchToDelete(null);
|
||||
}}
|
||||
onConfirm={confirmDeleteSavedSearch}
|
||||
title="Delete Saved Search"
|
||||
message="Are you sure you want to delete this saved search? This action cannot be undone."
|
||||
confirmText="Delete"
|
||||
type="danger"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -81,13 +81,16 @@ export const ColorSwitcherDropdown = () => {
|
||||
root.style.setProperty('--primary', hslColor);
|
||||
root.style.setProperty('--colors-primary', hslColor);
|
||||
|
||||
// Ensure background stays theme-appropriate
|
||||
if (isDark) {
|
||||
root.style.setProperty('--background', '0 0% 10%');
|
||||
root.style.setProperty('--colors-background', '0 0% 10%');
|
||||
root.style.setProperty('--foreground', '0 0% 98%');
|
||||
root.style.setProperty('--colors-foreground', '0 0% 98%');
|
||||
} else {
|
||||
root.style.setProperty('--background', '0 0% 100%');
|
||||
root.style.setProperty('--colors-background', '0 0% 100%');
|
||||
root.style.setProperty('--foreground', '0 0% 3.9%');
|
||||
root.style.setProperty('--colors-foreground', '0 0% 3.9%');
|
||||
}
|
||||
|
||||
if (closeDropdown) {
|
||||
|
||||
@@ -22,9 +22,10 @@ const Switch = (props: SwitchProps) => {
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={local.checked}
|
||||
data-state={local.checked ? 'checked' : 'unchecked'}
|
||||
class={cn(
|
||||
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
||||
local.checked ? 'data-[state=checked]' : 'data-[state=unchecked]',
|
||||
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50',
|
||||
local.checked ? 'bg-primary' : 'bg-input',
|
||||
props.class
|
||||
)}
|
||||
onClick={handleClick}
|
||||
@@ -33,7 +34,8 @@ const Switch = (props: SwitchProps) => {
|
||||
<span
|
||||
data-state={local.checked ? 'checked' : 'unchecked'}
|
||||
class={cn(
|
||||
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0'
|
||||
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform',
|
||||
local.checked ? 'translate-x-5' : 'translate-x-0'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { ModalPortal } from '@/components/ui/ModalPortal';
|
||||
import { Show } from 'solid-js';
|
||||
import { ConfirmModal } from '@/components/ui/ConfirmModal';
|
||||
import { Show, createSignal } from 'solid-js';
|
||||
import { NoteContentRenderer } from '@/components/notes/NoteContentRenderer';
|
||||
import { IconX, IconEdit, IconPin, IconTrash, IconCopy, IconDownload, IconPaperclip } from '@tabler/icons-solidjs';
|
||||
|
||||
@@ -71,6 +72,8 @@ export const ViewNoteModal = (props: ViewNoteModalProps) => {
|
||||
props.onUpdateNote(props.note.id, nextContent);
|
||||
};
|
||||
|
||||
const [showDeleteModal, setShowDeleteModal] = createSignal(false);
|
||||
|
||||
return (
|
||||
<ModalPortal>
|
||||
<>
|
||||
@@ -135,12 +138,7 @@ export const ViewNoteModal = (props: ViewNoteModalProps) => {
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
if (confirm('Are you sure you want to delete this note?')) {
|
||||
props.onDelete(props.note!.id);
|
||||
props.onClose();
|
||||
}
|
||||
}}
|
||||
onClick={() => setShowDeleteModal(true)}
|
||||
class="text-red-400 hover:text-red-300 p-1"
|
||||
>
|
||||
<IconTrash size={18} />
|
||||
@@ -212,6 +210,20 @@ export const ViewNoteModal = (props: ViewNoteModalProps) => {
|
||||
</div>
|
||||
</Show>
|
||||
</>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={showDeleteModal()}
|
||||
onClose={() => setShowDeleteModal(false)}
|
||||
onConfirm={() => {
|
||||
props.onDelete(props.note!.id);
|
||||
setShowDeleteModal(false);
|
||||
props.onClose();
|
||||
}}
|
||||
title="Delete Note"
|
||||
message="Are you sure you want to delete this note? This action cannot be undone."
|
||||
confirmText="Delete"
|
||||
type="danger"
|
||||
/>
|
||||
</ModalPortal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
// Brave Search API integration
|
||||
import { isDemoMode } from '@/lib/demo-mode';
|
||||
import { getApiV1BaseUrl } from '@/lib/api-url';
|
||||
|
||||
const BACKEND_API_URL = getApiV1BaseUrl();
|
||||
const BRAVE_API_KEY = import.meta.env.VITE_BRAVE_API_KEY || 'BSAw0HNI1v3rKmXlSTr0C_UfZDjw7fT';
|
||||
|
||||
// Use the variable to avoid unused warning
|
||||
console.log('Brave API key available:', !!BRAVE_API_KEY);
|
||||
|
||||
// Helper function to get auth headers
|
||||
const getAuthHeaders = () => {
|
||||
const isDemo = isDemoMode();
|
||||
|
||||
let token = null;
|
||||
|
||||
if (isDemo) {
|
||||
// In demo mode, use a mock token
|
||||
token = 'demo-token-' + Date.now();
|
||||
} else {
|
||||
// In normal mode, get token from localStorage
|
||||
token = localStorage.getItem('token') || localStorage.getItem('trackeep_token');
|
||||
}
|
||||
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { 'Authorization': `Bearer ${token}` }),
|
||||
};
|
||||
};
|
||||
|
||||
export interface BraveSearchResult {
|
||||
title: string;
|
||||
url: string;
|
||||
description: string;
|
||||
published_date?: string;
|
||||
language?: string;
|
||||
family_friendly?: boolean;
|
||||
type?: string;
|
||||
subtype?: string;
|
||||
}
|
||||
|
||||
export interface BraveSearchResponse {
|
||||
web?: {
|
||||
results: BraveSearchResult[];
|
||||
};
|
||||
news?: {
|
||||
results: BraveSearchResult[];
|
||||
};
|
||||
mixed?: {
|
||||
results: BraveSearchResult[];
|
||||
};
|
||||
query?: {
|
||||
original: string;
|
||||
display: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function searchBrave(query: string, count: number = 10, type: 'web' | 'news' = 'web'): Promise<BraveSearchResult[]> {
|
||||
try {
|
||||
// Use backend proxy to avoid CORS issues
|
||||
const endpoint = type === 'news' ? '/search/news' : '/search/web';
|
||||
const response = await fetch(`${BACKEND_API_URL}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
query,
|
||||
count,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Search API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Return results from the backend response
|
||||
if (data.results && Array.isArray(data.results)) {
|
||||
return data.results;
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Brave search error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchWeb(query: string, count: number = 10): Promise<BraveSearchResult[]> {
|
||||
return searchBrave(query, count, 'web');
|
||||
}
|
||||
|
||||
export async function searchNews(query: string, count: number = 10): Promise<BraveSearchResult[]> {
|
||||
return searchBrave(query, count, 'news');
|
||||
}
|
||||
|
||||
export async function getQuickSearchSuggestions(query: string, limit: number = 5): Promise<string[]> {
|
||||
try {
|
||||
const results = await searchBrave(query, limit);
|
||||
return results.map(result => result.title);
|
||||
} catch (error) {
|
||||
console.error('Failed to get search suggestions:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -540,17 +540,6 @@ const generateMockAISettings = () => ({
|
||||
},
|
||||
});
|
||||
|
||||
const generateMockSearchSettings = () => ({
|
||||
brave_api_key: '',
|
||||
brave_search_base_url: 'https://api.search.brave.com/res/v1/web/search',
|
||||
serper_api_key: '',
|
||||
serper_base_url: 'https://google.serper.dev/search',
|
||||
search_api_provider: 'brave',
|
||||
search_results_limit: 10,
|
||||
search_cache_ttl: 300,
|
||||
search_rate_limit: 100,
|
||||
});
|
||||
|
||||
const generateMockEmailSettings = () => ({
|
||||
smtp_enabled: false,
|
||||
smtp_host: '',
|
||||
@@ -1067,15 +1056,6 @@ export const demoFetch = async (url: string, options?: RequestInit): Promise<Res
|
||||
}
|
||||
}
|
||||
|
||||
if (path.includes('/api/v1/auth/search/settings')) {
|
||||
if (method === 'GET') {
|
||||
return jsonResponse(generateMockSearchSettings());
|
||||
}
|
||||
if (method === 'PUT') {
|
||||
return jsonResponse(body);
|
||||
}
|
||||
}
|
||||
|
||||
if (path.includes('/api/v1/auth/email/settings')) {
|
||||
if (method === 'GET') {
|
||||
return jsonResponse(generateMockEmailSettings());
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
const SOLIDTIME_BASE_URL = 'https://api.solidtime.io';
|
||||
|
||||
function getStoredCredentials() {
|
||||
const apiKey = localStorage.getItem('solidtime_api_key') || '';
|
||||
const orgId = localStorage.getItem('solidtime_org_id') || '';
|
||||
return { apiKey, orgId };
|
||||
}
|
||||
|
||||
async function solidtimeFetch(path: string, options: RequestInit = {}) {
|
||||
const { apiKey, orgId } = getStoredCredentials();
|
||||
if (!apiKey || !orgId) {
|
||||
throw new Error('Solidtime API credentials not configured');
|
||||
}
|
||||
|
||||
const url = `${SOLIDTIME_BASE_URL}/api/v1/organizations/${orgId}${path}`;
|
||||
const headers = {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
const response = await fetch(url, { ...options, headers });
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
throw new Error(`Solidtime API error: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return null;
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export const solidtimeApi = {
|
||||
getTimeEntries: async (params?: { start?: string; end?: string; active?: boolean }) => {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.start) query.set('start', params.start);
|
||||
if (params?.end) query.set('end', params.end);
|
||||
if (params?.active) query.set('active', 'true');
|
||||
const queryString = query.toString();
|
||||
const data = await solidtimeFetch(`/time-entries${queryString ? `?${queryString}` : ''}`);
|
||||
return data?.data || [];
|
||||
},
|
||||
|
||||
getActiveTimeEntry: async () => {
|
||||
const data = await solidtimeFetch('/time-entries?active=true');
|
||||
return data?.data?.[0] || null;
|
||||
},
|
||||
|
||||
startTimeEntry: async (payload: { description?: string; project_id?: string; task_id?: string; tags?: string[]; billable?: boolean }) => {
|
||||
const data = await solidtimeFetch('/time-entries', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
start: new Date().toISOString(),
|
||||
...payload,
|
||||
}),
|
||||
});
|
||||
return data?.data;
|
||||
},
|
||||
|
||||
stopTimeEntry: async (id: string) => {
|
||||
const data = await solidtimeFetch(`/time-entries/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
end: new Date().toISOString(),
|
||||
}),
|
||||
});
|
||||
return data?.data;
|
||||
},
|
||||
|
||||
updateTimeEntry: async (id: string, payload: { description?: string; project_id?: string; task_id?: string; tags?: string[]; billable?: boolean; start?: string; end?: string }) => {
|
||||
const data = await solidtimeFetch(`/time-entries/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
return data?.data;
|
||||
},
|
||||
|
||||
deleteTimeEntry: async (id: string) => {
|
||||
await solidtimeFetch(`/time-entries/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
getTimeEntriesStats: async () => {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const entries = await solidtimeApi.getTimeEntries({
|
||||
start: today.toISOString(),
|
||||
});
|
||||
|
||||
let total = 0;
|
||||
let billable = 0;
|
||||
let nonBillable = 0;
|
||||
|
||||
entries.forEach((entry: any) => {
|
||||
const start = new Date(entry.start).getTime();
|
||||
const end = entry.end ? new Date(entry.end).getTime() : Date.now();
|
||||
const duration = Math.floor((end - start) / 1000);
|
||||
total += duration;
|
||||
if (entry.billable) {
|
||||
billable += duration;
|
||||
} else {
|
||||
nonBillable += duration;
|
||||
}
|
||||
});
|
||||
|
||||
return { total, billable, nonBillable };
|
||||
},
|
||||
|
||||
getProjects: async () => {
|
||||
const data = await solidtimeFetch('/projects');
|
||||
return data?.data || [];
|
||||
},
|
||||
|
||||
getMembers: async () => {
|
||||
const data = await solidtimeFetch('/members');
|
||||
return data?.data || [];
|
||||
},
|
||||
|
||||
getTasks: async () => {
|
||||
const data = await solidtimeFetch('/tasks');
|
||||
return data?.data || [];
|
||||
},
|
||||
|
||||
getTags: async () => {
|
||||
const data = await solidtimeFetch('/tags');
|
||||
return data?.data || [];
|
||||
},
|
||||
|
||||
setCredentials: (apiKey: string, orgId: string) => {
|
||||
localStorage.setItem('solidtime_api_key', apiKey);
|
||||
localStorage.setItem('solidtime_org_id', orgId);
|
||||
},
|
||||
|
||||
getCredentials: getStoredCredentials,
|
||||
|
||||
clearCredentials: () => {
|
||||
localStorage.removeItem('solidtime_api_key');
|
||||
localStorage.removeItem('solidtime_org_id');
|
||||
},
|
||||
};
|
||||
@@ -138,6 +138,8 @@ export const Login = () => {
|
||||
fullName: formData().fullName,
|
||||
};
|
||||
await register(registerPayload);
|
||||
navigate('/app/workspace-setup', { replace: true });
|
||||
return;
|
||||
}
|
||||
navigate(getSafeNextPath(), { replace: true });
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { createSignal } from 'solid-js'
|
||||
import { useNavigate } from '@solidjs/router'
|
||||
import { getApiV1BaseUrl } from '@/lib/api-url'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Card } from '@/components/ui/Card'
|
||||
import { IconBuilding, IconCheck, IconWorld, IconLock } from '@tabler/icons-solidjs'
|
||||
import { useHaptics } from '@/lib/haptics'
|
||||
|
||||
const API_BASE_URL = getApiV1BaseUrl()
|
||||
|
||||
const WORKSPACE_ICONS = [
|
||||
{ id: 'building', icon: IconBuilding, label: 'Building' },
|
||||
{ id: 'world', icon: IconWorld, label: 'World' },
|
||||
{ id: 'lock', icon: IconLock, label: 'Lock' },
|
||||
{ id: 'check', icon: IconCheck, label: 'Check' },
|
||||
]
|
||||
|
||||
export const WorkspaceSetup = () => {
|
||||
const navigate = useNavigate()
|
||||
const haptics = useHaptics()
|
||||
const [name, setName] = createSignal('')
|
||||
const [description, setDescription] = createSignal('')
|
||||
const [isPublic, setIsPublic] = createSignal(false)
|
||||
const [selectedIcon, setSelectedIcon] = createSignal('building')
|
||||
const [isLoading, setIsLoading] = createSignal(false)
|
||||
const [error, setError] = createSignal('')
|
||||
|
||||
const handleCreate = async () => {
|
||||
const trimmed = name().trim()
|
||||
if (!trimmed) {
|
||||
setError('Workspace name required')
|
||||
return
|
||||
}
|
||||
setError('')
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token')
|
||||
const response = await fetch(`${API_BASE_URL}/teams`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: trimmed,
|
||||
description: description().trim(),
|
||||
is_public: isPublic()
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
throw new Error(data.error || 'Failed to create workspace')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const workspace = {
|
||||
id: String(data.team?.id || data.id),
|
||||
name: trimmed,
|
||||
icon: selectedIcon()
|
||||
}
|
||||
localStorage.setItem('trackeep_workspace_id', workspace.id)
|
||||
localStorage.setItem('trackeep_workspace_name', workspace.name)
|
||||
localStorage.setItem('trackeep_workspace_icon', workspace.icon)
|
||||
window.dispatchEvent(new CustomEvent('trackeep:workspace-changed', { detail: workspace }))
|
||||
haptics.success()
|
||||
navigate('/app', { replace: true })
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create workspace')
|
||||
haptics.error()
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="p-6 max-w-lg mx-auto">
|
||||
<h1 class="text-2xl font-bold text-foreground mb-2">Create Workspace</h1>
|
||||
<p class="text-muted-foreground mb-6">Set up your first workspace to get started.</p>
|
||||
|
||||
<Card class="p-6 space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-2">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name()}
|
||||
onInput={(e) => setName(e.currentTarget.value)}
|
||||
placeholder="My Workspace"
|
||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-2">Description</label>
|
||||
<textarea
|
||||
value={description()}
|
||||
onInput={(e) => setDescription(e.currentTarget.value)}
|
||||
placeholder="Optional description"
|
||||
class="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-2">Icon</label>
|
||||
<div class="flex gap-2">
|
||||
{WORKSPACE_ICONS.map((item) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedIcon(item.id)}
|
||||
class={`p-3 rounded-lg border transition-colors ${
|
||||
selectedIcon() === item.id
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-border hover:border-primary/50'
|
||||
}`}
|
||||
title={item.label}
|
||||
>
|
||||
<Icon class="size-5" />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isPublic()}
|
||||
onChange={(e) => setIsPublic(e.currentTarget.checked)}
|
||||
class="rounded border-input"
|
||||
/>
|
||||
<label class="text-sm text-foreground">Public workspace</label>
|
||||
</div>
|
||||
|
||||
{error() && <p class="text-sm text-destructive">{error()}</p>}
|
||||
|
||||
<Button onClick={handleCreate} disabled={isLoading()} class="w-full">
|
||||
{isLoading() ? 'Creating...' : 'Create Workspace'}
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,596 +0,0 @@
|
||||
import { createSignal, For, Show, onMount, createEffect } from 'solid-js'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Card } from '@/components/ui/Card'
|
||||
import {
|
||||
MessageCircle,
|
||||
Brain,
|
||||
Cog,
|
||||
Send,
|
||||
ChevronDown,
|
||||
User,
|
||||
Bot
|
||||
} from 'lucide-solid'
|
||||
import { AIProviderIcon } from '@/components/AIProviderIcon'
|
||||
import { useHaptics } from '@/lib/haptics'
|
||||
import { getApiOrigin } from '@/lib/api-url'
|
||||
|
||||
interface AIModel {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
provider: string
|
||||
category: string
|
||||
iconId?: string
|
||||
}
|
||||
|
||||
interface Message {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
timestamp: Date
|
||||
}
|
||||
|
||||
export const AIChat = () => {
|
||||
const haptics = useHaptics();
|
||||
const [activeView, setActiveView] = createSignal<'chat' | 'settings'>('chat')
|
||||
const [isSidebarOpen, setIsSidebarOpen] = createSignal(true)
|
||||
|
||||
// Chat state
|
||||
const [messages, setMessages] = createSignal<Message[]>([
|
||||
{
|
||||
id: '1',
|
||||
role: 'assistant',
|
||||
content: 'Hello! I\'m your AI assistant. How can I help you today?',
|
||||
timestamp: new Date()
|
||||
}
|
||||
])
|
||||
const [inputMessage, setInputMessage] = createSignal('')
|
||||
const [isLoading, setIsLoading] = createSignal(false)
|
||||
|
||||
// AI Model state
|
||||
const [selectedModel, setSelectedModel] = createSignal<string>('longcat-flash-chat')
|
||||
const [showModelPicker, setShowModelPicker] = createSignal(false)
|
||||
const [aiModels, setAIModels] = createSignal<AIModel[]>([])
|
||||
|
||||
// Initialize AI models
|
||||
onMount(() => {
|
||||
const checkMobile = () => {
|
||||
if (window.innerWidth < 768) {
|
||||
setIsSidebarOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
checkMobile()
|
||||
window.addEventListener('resize', checkMobile)
|
||||
|
||||
// Initialize AI models
|
||||
initializeAIModels()
|
||||
|
||||
return () => window.removeEventListener('resize', checkMobile)
|
||||
})
|
||||
|
||||
const initializeAIModels = () => {
|
||||
const models: AIModel[] = [
|
||||
{ id: 'longcat-flash-chat', name: 'LongCat Flash Chat', description: 'Fast and efficient chat model', provider: 'longcat', category: 'fast', iconId: 'longcat' },
|
||||
{ id: 'longcat-flash-thinking', name: 'LongCat Flash Thinking', description: 'Advanced reasoning model', provider: 'longcat', category: 'thinking', iconId: 'longcat' },
|
||||
{ id: 'mistral-small-latest', name: 'Mistral Small', description: 'Lightweight and fast', provider: 'mistral', category: 'standard', iconId: 'mistral' },
|
||||
{ id: 'mistral-large-latest', name: 'Mistral Large', description: 'Most capable model', provider: 'mistral', category: 'advanced', iconId: 'mistral' },
|
||||
{ id: 'grok-standard', name: 'Grok Standard', description: 'Grok from X', provider: 'grok', category: 'standard', iconId: 'grok' },
|
||||
{ id: 'deepseek-chat', name: 'DeepSeek Chat', description: 'DeepSeek chat model', provider: 'deepseek', category: 'standard', iconId: 'deepseek' },
|
||||
{ id: 'ollama-local', name: 'Ollama Local', description: 'Local Ollama model', provider: 'ollama', category: 'local', iconId: 'ollama' },
|
||||
{ id: 'openrouter-auto', name: 'OpenRouter Auto', description: 'Router over many models', provider: 'openrouter', category: 'standard', iconId: 'openrouter' },
|
||||
]
|
||||
setAIModels(models)
|
||||
}
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
const message = inputMessage().trim()
|
||||
if (!message || isLoading()) return
|
||||
|
||||
// Add user message
|
||||
const userMessage: Message = {
|
||||
id: Date.now().toString(),
|
||||
content: message,
|
||||
role: 'user',
|
||||
timestamp: new Date()
|
||||
}
|
||||
|
||||
setMessages(prev => [...prev, userMessage])
|
||||
setInputMessage('')
|
||||
setIsLoading(true)
|
||||
haptics.impact(); // Impact feedback for sending message
|
||||
|
||||
try {
|
||||
// Call AI API
|
||||
const response = await callAIAPI(message, selectedModel())
|
||||
|
||||
const aiMessage: Message = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: 'assistant',
|
||||
content: response,
|
||||
timestamp: new Date()
|
||||
}
|
||||
|
||||
setMessages(prev => [...prev, aiMessage])
|
||||
haptics.success(); // Success feedback for AI response
|
||||
} catch (error) {
|
||||
console.error('AI API call failed:', error)
|
||||
haptics.error(); // Error feedback
|
||||
|
||||
// Fallback response
|
||||
const errorMessage: Message = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: 'assistant',
|
||||
content: 'I apologize, but I encountered an error while processing your request. Please try again later.',
|
||||
timestamp: new Date()
|
||||
}
|
||||
|
||||
setMessages(prev => [...prev, errorMessage])
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const callAIAPI = async (message: string, modelId: string): Promise<string> => {
|
||||
const token = localStorage.getItem('token')
|
||||
const apiUrl = getApiOrigin()
|
||||
|
||||
const response = await fetch(`${apiUrl}/api/v1/ai/chat`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': token ? `Bearer ${token}` : '',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message,
|
||||
model: modelId,
|
||||
stream: false
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API call failed: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data.response || data.content || 'I understand your message. Let me help you with that.'
|
||||
}
|
||||
|
||||
|
||||
// Close model picker when clicking outside
|
||||
createEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
if (!target.closest('#model-picker-container')) {
|
||||
setShowModelPicker(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (showModelPicker()) {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
return () => document.removeEventListener('click', handleClickOutside)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
const startNewChat = () => {
|
||||
setMessages([{
|
||||
id: '1',
|
||||
role: 'assistant',
|
||||
content: 'Hello! I\'m your AI assistant. How can I help you today?',
|
||||
timestamp: new Date()
|
||||
}])
|
||||
setInputMessage('')
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div class="h-full w-full flex flex-col bg-background">
|
||||
{/* Header */}
|
||||
<header class="border-b bg-card/95 backdrop-blur-sm z-10">
|
||||
<div class="flex items-center justify-between px-4 py-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsSidebarOpen(!isSidebarOpen())}
|
||||
class="md:hidden"
|
||||
>
|
||||
<MessageCircle class="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* AI Logo */}
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-8 h-8 bg-muted rounded-lg flex items-center justify-center">
|
||||
<Brain class="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<h1 class="font-semibold text-lg">AI Assistant</h1>
|
||||
<p class="text-sm text-muted-foreground">Your intelligent workspace companion</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
{/* View Switcher */}
|
||||
<div class="flex items-center gap-1 p-1 bg-muted rounded-lg">
|
||||
<button
|
||||
onClick={() => setActiveView('chat')}
|
||||
class={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
activeView() === 'chat'
|
||||
? 'bg-background shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
Chat
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveView('settings')}
|
||||
class={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
activeView() === 'settings'
|
||||
? 'bg-background shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<Show when={isSidebarOpen()}>
|
||||
<aside class="w-80 border-r bg-card flex flex-col hidden md:flex">
|
||||
{/* Sidebar Header */}
|
||||
<div class="p-4 border-b">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="font-semibold">Chat Sessions</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setActiveView('settings')}
|
||||
>
|
||||
<Cog class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sessions List */}
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
<div class="space-y-3">
|
||||
{/* New Chat Button */}
|
||||
<Button
|
||||
onClick={startNewChat}
|
||||
class="w-full justify-start"
|
||||
variant="outline"
|
||||
>
|
||||
<MessageCircle class="h-4 w-4 mr-2" />
|
||||
New Chat
|
||||
</Button>
|
||||
|
||||
{/* Chat Sessions */}
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm text-muted-foreground font-medium px-3 py-2">
|
||||
Recent Chats
|
||||
</div>
|
||||
{[
|
||||
{ id: '1', title: 'Getting Started', message_count: 2, last_message: '2 hours ago' },
|
||||
{ id: '2', title: 'Project Planning', message_count: 5, last_message: '1 day ago' },
|
||||
{ id: '3', title: 'Technical Discussion', message_count: 3, last_message: '2 days ago' }
|
||||
].map(session => (
|
||||
<button
|
||||
class="w-full text-left p-3 rounded-lg hover:bg-muted transition-colors"
|
||||
onClick={() => {
|
||||
setMessages([{
|
||||
id: '1',
|
||||
content: `This is the ${session.title} session. How can I help you?`,
|
||||
role: 'assistant',
|
||||
timestamp: new Date()
|
||||
}])
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="font-medium truncate">{session.title}</h4>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{session.message_count} messages • {session.last_message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</Show>
|
||||
|
||||
{/* Main Content */}
|
||||
<main class="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Chat View */}
|
||||
<Show when={activeView() === 'chat'}>
|
||||
<div class="flex-1 flex flex-col">
|
||||
{/* Messages Area */}
|
||||
<div class="flex-1 overflow-y-auto p-6">
|
||||
<div class="max-w-4xl mx-auto space-y-6">
|
||||
<For each={messages()}>
|
||||
{message => (
|
||||
<div
|
||||
class={`flex gap-4 ${
|
||||
message.role === 'user' ? 'justify-end' : 'justify-start'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
class={`max-w-[80%] rounded-2xl p-4 shadow-sm transition-all duration-200 hover:shadow-md ${
|
||||
message.role === 'user'
|
||||
? 'bg-gradient-to-br from-primary to-primary/90 text-primary-foreground ml-auto'
|
||||
: 'bg-gradient-to-br from-muted to-muted/90 border border-border/50'
|
||||
}`}
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center transition-transform hover:scale-105 ${
|
||||
message.role === 'user' ? 'bg-primary-foreground/20 ring-2 ring-primary-foreground/30' : 'bg-primary/10 ring-2 ring-primary/20'
|
||||
}`}>
|
||||
{message.role === 'user' ? (
|
||||
<User class="text-xs" />
|
||||
) : (
|
||||
<Bot class="text-xs animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm leading-relaxed whitespace-pre-wrap break-words">{message.content}</p>
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<p class="text-xs opacity-70">
|
||||
{message.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</p>
|
||||
{message.role === 'user' && (
|
||||
<div class="w-2 h-2 bg-primary-foreground/50 rounded-full"></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
|
||||
{isLoading() && (
|
||||
<div class="flex justify-start">
|
||||
<div class="bg-gradient-to-br from-muted to-muted/90 rounded-2xl p-4 max-w-[80%] border border-border/50 shadow-sm">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-primary/10 ring-2 ring-primary/20 flex items-center justify-center">
|
||||
<Bot class="text-xs animate-pulse" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex gap-1">
|
||||
<div class="w-2 h-2 bg-primary rounded-full animate-bounce"></div>
|
||||
<div class="w-2 h-2 bg-primary rounded-full animate-bounce" style="animation-delay: 0.1s"></div>
|
||||
<div class="w-2 h-2 bg-primary rounded-full animate-bounce" style="animation-delay: 0.2s"></div>
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground ml-2">AI is thinking...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Input Area */}
|
||||
<div class="border-t bg-card/95 backdrop-blur-sm">
|
||||
<div class="p-6">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="flex gap-4">
|
||||
{/* AI Model Switcher */}
|
||||
<div id="model-picker-container" class="relative">
|
||||
<button
|
||||
onClick={() => setShowModelPicker(!showModelPicker())}
|
||||
class="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-muted to-muted/80 hover:from-muted/90 hover:to-muted/70 rounded-xl text-sm transition-all duration-200 border border-border/50 shadow-sm hover:shadow-md"
|
||||
>
|
||||
<AIProviderIcon
|
||||
providerId={aiModels().find(m => m.id === selectedModel())?.iconId || 'longcat'}
|
||||
size="1rem"
|
||||
class="transition-transform hover:scale-110"
|
||||
/>
|
||||
<span class="text-sm font-medium">
|
||||
{aiModels().find(m => m.id === selectedModel())?.name?.split(' ')[0] || 'AI'}
|
||||
</span>
|
||||
<ChevronDown class={`h-4 w-4 transition-transform duration-200 ${showModelPicker() ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{/* Model Picker Dropdown */}
|
||||
<Show when={showModelPicker()}>
|
||||
<div class="absolute bottom-full left-0 mb-2 w-80 bg-gradient-to-b from-background to-background/95 backdrop-blur-sm border border-border/50 rounded-xl shadow-xl z-50 p-2 max-h-96 overflow-y-auto">
|
||||
<div class="p-3 border-b border-border/50 mb-2 bg-muted/30 rounded-lg">
|
||||
<h4 class="text-sm font-semibold text-foreground">Select AI Model</h4>
|
||||
<p class="text-xs text-muted-foreground">Choose the best model for your needs</p>
|
||||
</div>
|
||||
<For each={aiModels()}>
|
||||
{model => (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedModel(model.id)
|
||||
setShowModelPicker(false)
|
||||
}}
|
||||
class={`w-full text-left p-3 rounded-lg transition-all duration-200 ${
|
||||
selectedModel() === model.id
|
||||
? 'bg-gradient-to-r from-primary/10 to-primary/5 border border-primary/30 shadow-sm'
|
||||
: 'hover:bg-muted/50 hover:border-border/30'
|
||||
}`}
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3 flex-1">
|
||||
<AIProviderIcon
|
||||
providerId={model.iconId!}
|
||||
size="1rem"
|
||||
class="rounded-full flex-shrink-0"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium text-sm truncate">{model.name}</div>
|
||||
<div class="text-xs text-muted-foreground mt-1 truncate">{model.description}</div>
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<span class="text-xs px-2 py-1 bg-primary/10 text-primary rounded-full">
|
||||
{model.provider}
|
||||
</span>
|
||||
<span class={`text-xs px-2 py-1 rounded-full ${
|
||||
model.category === 'thinking'
|
||||
? 'bg-purple-100 text-purple-800'
|
||||
: model.category === 'fast'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: model.category === 'advanced'
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}>
|
||||
{model.category}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{selectedModel() === model.id && (
|
||||
<div class="w-2 h-2 bg-primary rounded-full animate-pulse flex-shrink-0"></div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="relative flex-1">
|
||||
<div class="relative">
|
||||
<Input
|
||||
value={inputMessage()}
|
||||
onInput={(e) => setInputMessage((e.currentTarget as HTMLInputElement).value)}
|
||||
placeholder="Type your message..."
|
||||
class="flex-1 pr-12 rounded-xl border-border/50 bg-background/95 backdrop-blur-sm shadow-sm transition-all duration-200 focus:shadow-md focus:border-primary/50"
|
||||
onKeyDown={(e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && inputMessage().trim()) {
|
||||
handleSendMessage()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div class="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
|
||||
<div class="w-1 h-1 bg-muted-foreground/40 rounded-full animate-pulse"></div>
|
||||
<div class="w-1 h-1 bg-muted-foreground/40 rounded-full animate-pulse" style="animation-delay: 0.2s"></div>
|
||||
<div class="w-1 h-1 bg-muted-foreground/40 rounded-full animate-pulse" style="animation-delay: 0.4s"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
disabled={isLoading() || !inputMessage().trim()}
|
||||
onClick={() => {
|
||||
handleSendMessage();
|
||||
haptics.impact();
|
||||
}}
|
||||
class="rounded-xl px-4 py-2.5 bg-gradient-to-r from-primary to-primary/90 hover:from-primary/90 hover:to-primary shadow-sm hover:shadow-md transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Send class="h-4 w-4" />
|
||||
<span class="ml-2 text-sm font-medium">Send</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Settings View */}
|
||||
<Show when={activeView() === 'settings'}>
|
||||
<div class="flex-1 overflow-y-auto p-6">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="mb-8">
|
||||
<h2 class="text-2xl font-bold mb-2">AI Settings</h2>
|
||||
<p class="text-muted-foreground">Configure your AI models and preferences</p>
|
||||
</div>
|
||||
|
||||
<Card class="p-6">
|
||||
<h3 class="text-lg font-semibold mb-4">Available AI Models</h3>
|
||||
<div class="space-y-4">
|
||||
<For each={aiModels()}>
|
||||
{(model) => (
|
||||
<div
|
||||
class={`p-4 border rounded-lg transition-all ${
|
||||
selectedModel() === model.id
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:bg-muted/50'
|
||||
}`}
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<AIProviderIcon
|
||||
providerId={model.iconId!}
|
||||
size="2rem"
|
||||
class="text-primary"
|
||||
/>
|
||||
<div>
|
||||
<h5 class="font-medium">{model.name}</h5>
|
||||
<p class="text-sm text-muted-foreground">{model.description}</p>
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<span class="text-xs px-2 py-1 bg-primary/10 text-primary rounded-full">
|
||||
{model.provider}
|
||||
</span>
|
||||
<span class={`text-xs px-2 py-1 rounded-full ${
|
||||
model.category === 'thinking'
|
||||
? 'bg-purple-100 text-purple-800'
|
||||
: model.category === 'fast'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: model.category === 'advanced'
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}>
|
||||
{model.category}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedModel(model.id)}
|
||||
class={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
selectedModel() === model.id
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted hover:bg-muted/80'
|
||||
}`}
|
||||
>
|
||||
{selectedModel() === model.id ? 'Selected' : 'Select'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card class="p-6 mt-6">
|
||||
<h3 class="text-lg font-semibold mb-4">Current Selection</h3>
|
||||
<div class="p-4 bg-muted/50 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<AIProviderIcon
|
||||
providerId={aiModels().find(m => m.id === selectedModel())?.iconId || 'longcat'}
|
||||
size="1.5rem"
|
||||
class="text-primary"
|
||||
/>
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{aiModels().find(m => m.id === selectedModel())?.name}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{aiModels().find(m => m.id === selectedModel())?.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AIChat
|
||||
@@ -3,7 +3,6 @@ import { getApiOrigin } from '@/lib/api-url'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Card } from '@/components/ui/Card'
|
||||
import { AIProviderIcon } from '@/components/AIProviderIcon'
|
||||
import {
|
||||
Send,
|
||||
CheckSquare,
|
||||
@@ -736,11 +735,7 @@ const Chat = () => {
|
||||
{message.role === 'user' ? (
|
||||
<User class="w-4 h-4 text-xs" />
|
||||
) : (
|
||||
<AIProviderIcon
|
||||
providerId={selectedModel()}
|
||||
size="1rem"
|
||||
class="text-primary animate-pulse"
|
||||
/>
|
||||
<Sparkles class="w-4 h-4 text-primary animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
@@ -771,11 +766,7 @@ const Chat = () => {
|
||||
onClick={() => setShowModelPicker(!showModelPicker())}
|
||||
class="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-muted to-muted/80 hover:from-muted/90 hover:to-muted/70 rounded-xl text-sm transition-all duration-200 border border-border/50 shadow-sm hover:shadow-md"
|
||||
>
|
||||
<AIProviderIcon
|
||||
providerId={selectedModel()}
|
||||
size="1rem"
|
||||
class="transition-transform hover:scale-110"
|
||||
/>
|
||||
<Sparkles class="w-4 h-4 transition-transform hover:scale-110" />
|
||||
<span class="text-sm font-medium">
|
||||
{getAIModels().find(m => m.id === selectedModel())?.name || 'Select Model'}
|
||||
</span>
|
||||
@@ -803,10 +794,7 @@ const Chat = () => {
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3 flex-1">
|
||||
<AIProviderIcon
|
||||
providerId={model.id}
|
||||
size="1rem"
|
||||
/>
|
||||
<Sparkles class="w-4 h-4" />
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-sm">{model.name}</div>
|
||||
<div class="text-xs text-muted-foreground mt-1">{model.description}</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import { Button } from '@/components/ui/Button';
|
||||
import { BookmarkModal } from '@/components/ui/BookmarkModal';
|
||||
import { EditBookmarkModal } from '@/components/ui/EditBookmarkModal';
|
||||
import { VideoUploadModal } from '@/components/ui/VideoUploadModal';
|
||||
import { ConfirmModal } from '@/components/ui/ConfirmModal';
|
||||
import { DropdownMenu, DropdownMenuItem } from '@/components/ui/DropdownMenu';
|
||||
import { SearchTagFilterBar } from '@/components/ui/SearchTagFilterBar';
|
||||
import { IconDotsVertical, IconStar, IconEdit, IconTrash, IconExternalLink, IconVideo, IconBookmark } from '@tabler/icons-solidjs';
|
||||
@@ -267,27 +268,39 @@ export const Bookmarks = () => {
|
||||
haptics.selection(); // Selection feedback for starring
|
||||
};
|
||||
|
||||
const [showDeleteModal, setShowDeleteModal] = createSignal(false);
|
||||
const [bookmarkToDelete, setBookmarkToDelete] = createSignal<number | null>(null);
|
||||
const [modalError, setModalError] = createSignal('');
|
||||
|
||||
const deleteBookmark = async (bookmarkId: number) => {
|
||||
if (confirm('Are you sure you want to delete this bookmark?')) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/bookmarks/${bookmarkId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': localStorage.getItem('trackeep_token') ? `Bearer ${localStorage.getItem('trackeep_token')}` : '',
|
||||
},
|
||||
});
|
||||
setBookmarkToDelete(bookmarkId);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to delete bookmark');
|
||||
}
|
||||
const confirmDeleteBookmark = async () => {
|
||||
const bookmarkId = bookmarkToDelete();
|
||||
if (!bookmarkId) return;
|
||||
|
||||
setBookmarks(prev => prev.filter(bookmark => bookmark.id !== bookmarkId));
|
||||
haptics.delete(); // Delete feedback
|
||||
} catch (error) {
|
||||
haptics.error(); // Error feedback
|
||||
alert(error instanceof Error ? error.message : 'Failed to delete bookmark');
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/bookmarks/${bookmarkId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': localStorage.getItem('trackeep_token') ? `Bearer ${localStorage.getItem('trackeep_token')}` : '',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to delete bookmark');
|
||||
}
|
||||
|
||||
setBookmarks(prev => prev.filter(bookmark => bookmark.id !== bookmarkId));
|
||||
haptics.delete(); // Delete feedback
|
||||
setShowDeleteModal(false);
|
||||
setBookmarkToDelete(null);
|
||||
} catch (error) {
|
||||
haptics.error(); // Error feedback
|
||||
setModalError(error instanceof Error ? error.message : 'Failed to delete bookmark');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -774,6 +787,20 @@ export const Bookmarks = () => {
|
||||
onClose={() => setShowVideoModal(false)}
|
||||
onSubmit={handleVideoSubmit}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={showDeleteModal()}
|
||||
onClose={() => {
|
||||
setShowDeleteModal(false);
|
||||
setBookmarkToDelete(null);
|
||||
setModalError('');
|
||||
}}
|
||||
onConfirm={confirmDeleteBookmark}
|
||||
title="Delete Bookmark"
|
||||
message={modalError() || 'Are you sure you want to delete this bookmark? This action cannot be undone.'}
|
||||
confirmText="Delete"
|
||||
type="danger"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -24,12 +24,10 @@ import {
|
||||
IconChartLine,
|
||||
IconActivity,
|
||||
IconSearch,
|
||||
IconChevronDown,
|
||||
IconVideo,
|
||||
IconSchool,
|
||||
IconX
|
||||
} from '@tabler/icons-solidjs';
|
||||
import { BrowserSearch } from '@/components/search/BrowserSearch';
|
||||
import { DropdownMenu, DropdownMenuItem } from '@/components/ui/DropdownMenu';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { FilePreviewModal } from '@/components/ui/FilePreviewModal';
|
||||
@@ -210,7 +208,7 @@ export const Dashboard = () => {
|
||||
const [documents, setDocuments] = createSignal<Document[]>([]);
|
||||
const [, setRecentActivity] = createSignal<RecentActivity[]>([]);
|
||||
const [githubActivityEvents, setGithubActivityEvents] = createSignal<GitHubActivityEvent[]>([]);
|
||||
const [showBrowserSearch, setShowBrowserSearch] = createSignal(true);
|
||||
|
||||
const [showFilePreview, setShowFilePreview] = createSignal(false);
|
||||
const [selectedFile, setSelectedFile] = createSignal<Document | null>(null);
|
||||
const [currentPage, setCurrentPage] = createSignal(1);
|
||||
@@ -293,9 +291,6 @@ export const Dashboard = () => {
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
// Load browser search setting from localStorage
|
||||
setShowBrowserSearch(localStorage.getItem('showBrowserSearch') !== 'false');
|
||||
|
||||
if (isDemoMode()) {
|
||||
setDashboardStats(getMockStats());
|
||||
setDocuments(getMockDocuments());
|
||||
@@ -961,40 +956,6 @@ export const Dashboard = () => {
|
||||
<Show when={isSearchAvailable()}>
|
||||
<div class="mb-8">
|
||||
<div class="border rounded-lg">
|
||||
{/* Collapsible Header */}
|
||||
<button
|
||||
onClick={() => {
|
||||
const newState = !showBrowserSearch();
|
||||
setShowBrowserSearch(newState);
|
||||
localStorage.setItem('showBrowserSearch', newState.toString());
|
||||
}}
|
||||
class="w-full flex items-center justify-between p-4 hover:bg-accent/50 transition-colors rounded-t-lg"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<IconSearch class="size-4 text-primary" />
|
||||
<h2 class="text-lg font-semibold">Browser Search</h2>
|
||||
<span class="text-xs text-muted-foreground bg-muted px-2 py-1 rounded">
|
||||
Powered by Brave Search
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{showBrowserSearch() ? 'Hide' : 'Show'}
|
||||
</span>
|
||||
<IconChevronDown
|
||||
class={`size-4 text-muted-foreground transition-transform duration-200 ${
|
||||
showBrowserSearch() ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Collapsible Content */}
|
||||
<Show when={showBrowserSearch()}>
|
||||
<div class="border-t border-border p-4">
|
||||
<BrowserSearch />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createEffect, createSignal, onMount, Show } from 'solid-js';
|
||||
import { IconTrash, IconRestore, IconFileText, IconFileTypePpt, IconFileTypeDocx, IconClock, IconSettings, IconAlertTriangle } from '@tabler/icons-solidjs';
|
||||
import { ConfirmModal } from '@/components/ui/ConfirmModal';
|
||||
import { getApiV1BaseUrl } from '@/lib/api-url';
|
||||
|
||||
interface RemovedItem {
|
||||
@@ -31,6 +32,12 @@ export const RemovedStuff = () => {
|
||||
const [showSettings, setShowSettings] = createSignal(false);
|
||||
const [selectedItems, setSelectedItems] = createSignal<string[]>([]);
|
||||
const [loadedRemovedItems, setLoadedRemovedItems] = createSignal(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = createSignal(false);
|
||||
const [deleteModalConfig, setDeleteModalConfig] = createSignal({
|
||||
title: 'Delete Item',
|
||||
message: 'Are you sure you want to delete this item?',
|
||||
onConfirm: () => {}
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (!loadedRemovedItems()) return;
|
||||
@@ -167,48 +174,59 @@ export const RemovedStuff = () => {
|
||||
};
|
||||
|
||||
const handleEmptyTrash = () => {
|
||||
if (confirm('Are you sure you want to permanently delete all items in the trash? This action cannot be undone.')) {
|
||||
setRemovedItems([]);
|
||||
alert('Trash emptied successfully!');
|
||||
}
|
||||
setDeleteModalConfig({
|
||||
title: 'Empty Trash',
|
||||
message: 'Are you sure you want to permanently delete all items in the trash? This action cannot be undone.',
|
||||
onConfirm: () => {
|
||||
setRemovedItems([]);
|
||||
setShowDeleteModal(false);
|
||||
}
|
||||
});
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const handleRestoreItem = (id: string) => {
|
||||
const item = removedItems().find(item => item.id === id);
|
||||
if (item) {
|
||||
setRemovedItems(prev => prev.filter(item => item.id !== id));
|
||||
alert(`"${item.name}" has been restored successfully!`);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePermanentlyDelete = (id: string) => {
|
||||
const item = removedItems().find(item => item.id === id);
|
||||
if (item && confirm(`Are you sure you want to permanently delete "${item.name}"? This action cannot be undone.`)) {
|
||||
setRemovedItems(prev => prev.filter(item => item.id !== id));
|
||||
alert(`"${item.name}" has been permanently deleted!`);
|
||||
if (item) {
|
||||
setDeleteModalConfig({
|
||||
title: 'Delete Permanently',
|
||||
message: `Are you sure you want to permanently delete "${item.name}"? This action cannot be undone.`,
|
||||
onConfirm: () => {
|
||||
setRemovedItems(prev => prev.filter(item => item.id !== id));
|
||||
setShowDeleteModal(false);
|
||||
}
|
||||
});
|
||||
setShowDeleteModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkRestore = () => {
|
||||
if (selectedItems().length === 0) return;
|
||||
|
||||
if (confirm(`Are you sure you want to restore ${selectedItems().length} items?`)) {
|
||||
const itemsToRestore = removedItems().filter(item => selectedItems().includes(item.id));
|
||||
setRemovedItems(prev => prev.filter(item => !selectedItems().includes(item.id)));
|
||||
setSelectedItems([]);
|
||||
alert(`${itemsToRestore.length} items have been restored successfully!`);
|
||||
}
|
||||
setRemovedItems(prev => prev.filter(item => !selectedItems().includes(item.id)));
|
||||
setSelectedItems([]);
|
||||
};
|
||||
|
||||
const handleBulkDelete = () => {
|
||||
if (selectedItems().length === 0) return;
|
||||
|
||||
if (confirm(`Are you sure you want to permanently delete ${selectedItems().length} items? This action cannot be undone.`)) {
|
||||
const itemsToDelete = removedItems().filter(item => selectedItems().includes(item.id));
|
||||
setRemovedItems(prev => prev.filter(item => !selectedItems().includes(item.id)));
|
||||
setSelectedItems([]);
|
||||
alert(`${itemsToDelete.length} items have been permanently deleted!`);
|
||||
}
|
||||
setDeleteModalConfig({
|
||||
title: 'Delete Permanently',
|
||||
message: `Are you sure you want to permanently delete ${selectedItems().length} items? This action cannot be undone.`,
|
||||
onConfirm: () => {
|
||||
setRemovedItems(prev => prev.filter(item => !selectedItems().includes(item.id)));
|
||||
setSelectedItems([]);
|
||||
setShowDeleteModal(false);
|
||||
}
|
||||
});
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const getItemsReadyForAutoRemove = () => {
|
||||
@@ -442,6 +460,16 @@ export const RemovedStuff = () => {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={showDeleteModal()}
|
||||
onClose={() => setShowDeleteModal(false)}
|
||||
onConfirm={() => deleteModalConfig().onConfirm()}
|
||||
title={deleteModalConfig().title}
|
||||
message={deleteModalConfig().message}
|
||||
confirmText="Delete"
|
||||
type="danger"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,234 +0,0 @@
|
||||
import { createSignal, onMount } from 'solid-js';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { IconBrain, IconFileText, IconChecklist, IconSparkles, IconRobot, IconSettings } from '@tabler/icons-solidjs';
|
||||
import { AIProviderIcon } from '@/components/AIProviderIcon';
|
||||
|
||||
interface AIModel {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
provider: string;
|
||||
category: string;
|
||||
iconId?: string;
|
||||
}
|
||||
|
||||
export const AIAssistant = () => {
|
||||
const [activeTab, setActiveTab] = createSignal<'dashboard' | 'summarizer' | 'tasks' | 'content' | 'settings'>('dashboard');
|
||||
const [selectedModel, setSelectedModel] = createSignal<string>('longcat-flash-chat');
|
||||
const [aiModels, setAIModels] = createSignal<AIModel[]>([]);
|
||||
|
||||
const tabs = [
|
||||
{ id: 'dashboard', label: 'AI Dashboard', icon: IconBrain },
|
||||
{ id: 'summarizer', label: 'Content Summarizer', icon: IconFileText },
|
||||
{ id: 'tasks', label: 'Task Suggestions', icon: IconChecklist },
|
||||
{ id: 'content', label: 'Content Generation', icon: IconSparkles },
|
||||
{ id: 'settings', label: 'AI Settings', icon: IconSettings },
|
||||
];
|
||||
|
||||
// Initialize AI models on mount
|
||||
onMount(() => {
|
||||
const models: AIModel[] = [
|
||||
{ id: 'longcat-flash-chat', name: 'LongCat Flash Chat', description: 'Fast and efficient chat model', provider: 'longcat', category: 'fast', iconId: 'longcat' },
|
||||
{ id: 'longcat-flash-thinking', name: 'LongCat Flash Thinking', description: 'Advanced reasoning model', provider: 'longcat', category: 'thinking', iconId: 'longcat' },
|
||||
{ id: 'mistral-small-latest', name: 'Mistral Small', description: 'Lightweight and fast', provider: 'mistral', category: 'standard', iconId: 'mistral' },
|
||||
{ id: 'mistral-large-latest', name: 'Mistral Large', description: 'Most capable model', provider: 'mistral', category: 'advanced', iconId: 'mistral' },
|
||||
{ id: 'grok-standard', name: 'Grok Standard', description: 'Grok from X', provider: 'grok', category: 'standard', iconId: 'grok' },
|
||||
{ id: 'deepseek-chat', name: 'DeepSeek Chat', description: 'DeepSeek chat model', provider: 'deepseek', category: 'standard', iconId: 'deepseek' },
|
||||
{ id: 'ollama-local', name: 'Ollama Local', description: 'Local Ollama model', provider: 'ollama', category: 'local', iconId: 'ollama' },
|
||||
{ id: 'openrouter-auto', name: 'OpenRouter Auto', description: 'Router over many models', provider: 'openrouter', category: 'standard', iconId: 'openrouter' },
|
||||
];
|
||||
setAIModels(models);
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
{/* Header */}
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<IconRobot class="size-8 text-primary" />
|
||||
AI Assistant
|
||||
</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-2">
|
||||
Leverage AI to enhance your productivity and content management
|
||||
</p>
|
||||
</div>
|
||||
{aiModels().length > 0 && (
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
<span class="text-gray-500">Active:</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-1 px-2 py-1 bg-blue-50 dark:bg-blue-900/20 rounded-md">
|
||||
<AIProviderIcon
|
||||
providerId={aiModels().find(m => m.id === selectedModel())?.iconId || 'longcat'}
|
||||
size="1.25rem"
|
||||
class="text-primary"
|
||||
/>
|
||||
<span class="font-medium text-blue-600 dark:text-blue-400">
|
||||
{aiModels().find(m => m.id === selectedModel())?.name?.split(' ')[0] || 'AI'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div class="border-b border-gray-200 dark:border-gray-700">
|
||||
<nav class="-mb-px flex space-x-8">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
onClick={() => setActiveTab(tab.id as any)}
|
||||
class={`flex items-center gap-2 py-2 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab() === tab.id
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<tab.icon class="size-5" />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div class="space-y-6">
|
||||
{activeTab() === 'settings' && (
|
||||
<Card class="p-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">AI Provider Settings</h3>
|
||||
<div class="space-y-6">
|
||||
{/* AI Models */}
|
||||
<div>
|
||||
<h4 class="text-md font-medium text-gray-800 dark:text-gray-200 mb-3">Available AI Models</h4>
|
||||
<div class="space-y-3">
|
||||
{aiModels().map((model) => (
|
||||
<div
|
||||
class={`p-4 border rounded-lg transition-all ${
|
||||
selectedModel() === model.id
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'border-gray-200 dark:border-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<AIProviderIcon
|
||||
providerId={model.iconId!}
|
||||
size="2rem"
|
||||
class="text-primary"
|
||||
/>
|
||||
<div>
|
||||
<h5 class="font-medium text-gray-900 dark:text-white">{model.name}</h5>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{model.description}</p>
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<span class="text-xs px-2 py-1 bg-blue-100 text-blue-800 rounded-full">
|
||||
{model.provider}
|
||||
</span>
|
||||
<span class={`text-xs px-2 py-1 rounded-full ${
|
||||
model.category === 'thinking'
|
||||
? 'bg-purple-100 text-purple-800'
|
||||
: model.category === 'fast'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: model.category === 'advanced'
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{model.category}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedModel(model.id)}
|
||||
class={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
selectedModel() === model.id
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{selectedModel() === model.id ? 'Selected' : 'Select'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Selection */}
|
||||
<div>
|
||||
<h4 class="text-md font-medium text-gray-800 dark:text-gray-200 mb-3">Current Selection</h4>
|
||||
<div class="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<AIProviderIcon
|
||||
providerId={aiModels().find(m => m.id === selectedModel())?.iconId || 'longcat'}
|
||||
size="1.5rem"
|
||||
class="text-primary"
|
||||
/>
|
||||
<div>
|
||||
<p class="font-medium text-gray-900 dark:text-white">
|
||||
{aiModels().find(m => m.id === selectedModel())?.name}
|
||||
</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{aiModels().find(m => m.id === selectedModel())?.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab() === 'dashboard' && (
|
||||
<Card class="p-6 text-center">
|
||||
<IconBrain class="size-12 text-primary mx-auto" />
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2 mt-4">
|
||||
AI Dashboard
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
AI Dashboard component temporarily disabled.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
{activeTab() === 'summarizer' && (
|
||||
<Card class="p-6 text-center">
|
||||
<IconFileText class="size-12 text-primary mx-auto" />
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2 mt-4">
|
||||
Content Summarizer
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
Content Summarizer component temporarily disabled.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
{activeTab() === 'tasks' && (
|
||||
<Card class="p-6 text-center">
|
||||
<IconChecklist class="size-12 text-primary mx-auto" />
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2 mt-4">
|
||||
Task Suggestions
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
AI-powered task suggestions based on your calendar, deadlines, and habits.
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 mt-2">
|
||||
View and manage suggestions from the AI Dashboard.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
{activeTab() === 'content' && (
|
||||
<Card class="p-6 text-center">
|
||||
<IconSparkles class="size-12 text-primary mx-auto" />
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2 mt-4">
|
||||
Content Generation
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
Generate blog posts, code, emails, and more with AI assistance.
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 mt-2">
|
||||
Coming soon - Advanced AI content generation tools.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import { createSignal, onMount } from 'solid-js';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { TaskModal } from '@/components/ui/TaskModal';
|
||||
import { ConfirmModal } from '@/components/ui/ConfirmModal';
|
||||
import { IconEdit, IconTrash } from '@tabler/icons-solidjs';
|
||||
import { getApiV1BaseUrl } from '@/lib/api-url';
|
||||
import { useHaptics } from '@/lib/haptics';
|
||||
@@ -28,6 +29,9 @@ export const Tasks = () => {
|
||||
const [selectedPriority, setSelectedPriority] = createSignal('');
|
||||
const [draggedTaskId, setDraggedTaskId] = createSignal<number | null>(null);
|
||||
const [dragOverColumn, setDragOverColumn] = createSignal<string | null>(null);
|
||||
const [showDeleteModal, setShowDeleteModal] = createSignal(false);
|
||||
const [taskToDelete, setTaskToDelete] = createSignal<number | null>(null);
|
||||
const [modalError, setModalError] = createSignal('');
|
||||
const [taskStatuses, setTaskStatuses] = createSignal<Record<number, 'todo' | 'inProgress' | 'done'>>({});
|
||||
|
||||
const haptics = useHaptics();
|
||||
@@ -174,26 +178,34 @@ export const Tasks = () => {
|
||||
};
|
||||
|
||||
const deleteTask = async (taskId: number) => {
|
||||
if (confirm('Are you sure you want to delete this task?')) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/tasks/${taskId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': localStorage.getItem('trackeep_token') ? `Bearer ${localStorage.getItem('trackeep_token')}` : '',
|
||||
},
|
||||
});
|
||||
setTaskToDelete(taskId);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to delete task');
|
||||
}
|
||||
const confirmDeleteTask = async () => {
|
||||
const taskId = taskToDelete();
|
||||
if (!taskId) return;
|
||||
|
||||
setTasks(prev => prev.filter(task => task.id !== taskId));
|
||||
haptics.delete(); // Delete feedback
|
||||
} catch (error) {
|
||||
haptics.error(); // Error feedback
|
||||
alert(error instanceof Error ? error.message : 'Failed to delete task');
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/tasks/${taskId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': localStorage.getItem('trackeep_token') ? `Bearer ${localStorage.getItem('trackeep_token')}` : '',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to delete task');
|
||||
}
|
||||
|
||||
setTasks(prev => prev.filter(task => task.id !== taskId));
|
||||
haptics.delete(); // Delete feedback
|
||||
setShowDeleteModal(false);
|
||||
setTaskToDelete(null);
|
||||
} catch (error) {
|
||||
haptics.error(); // Error feedback
|
||||
setModalError(error instanceof Error ? error.message : 'Failed to delete task');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -342,6 +354,20 @@ export const Tasks = () => {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={showDeleteModal()}
|
||||
onClose={() => {
|
||||
setShowDeleteModal(false);
|
||||
setTaskToDelete(null);
|
||||
setModalError('');
|
||||
}}
|
||||
onConfirm={confirmDeleteTask}
|
||||
title="Delete Task"
|
||||
message={modalError() || 'Are you sure you want to delete this task? This action cannot be undone.'}
|
||||
confirmText="Delete"
|
||||
type="danger"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,204 +1,233 @@
|
||||
import { createSignal, createEffect, onMount } from 'solid-js';
|
||||
import { Timer } from '@/components/Timer';
|
||||
import { TimeEntriesList } from '@/components/TimeEntriesList';
|
||||
import { type TimeEntry, timeEntriesApi, demoTimeEntriesApi } from '@/lib/api';
|
||||
import { IconClock, IconActivity, IconCurrencyDollar } from '@tabler/icons-solidjs';
|
||||
import { isDemoMode } from '@/lib/demo-mode';
|
||||
import { useHaptics } from '@/lib/haptics';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { LoadingState } from '@/components/ui/LoadingState';
|
||||
import { solidtimeApi } from '@/lib/solidtime-api';
|
||||
|
||||
export const TimeTracking = () => {
|
||||
const haptics = useHaptics();
|
||||
const [refreshTrigger, setRefreshTrigger] = createSignal(0);
|
||||
const [timeEntries, setTimeEntries] = createSignal<TimeEntry[]>([]);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [currentRunningEntry, setCurrentRunningEntry] = createSignal<TimeEntry | null>(null);
|
||||
const [currentElapsedSeconds, setCurrentElapsedSeconds] = createSignal(0);
|
||||
const [timeEntries, setTimeEntries] = createSignal<any[]>([]);
|
||||
const [currentRunningEntry, setCurrentRunningEntry] = createSignal<any | null>(null);
|
||||
const [elapsedSeconds, setElapsedSeconds] = createSignal(0);
|
||||
const [stats, setStats] = createSignal({ total: 0, billable: 0, nonBillable: 0 });
|
||||
|
||||
// Use appropriate API based on demo mode
|
||||
const getApi = () => isDemoMode() ? demoTimeEntriesApi : timeEntriesApi;
|
||||
let timerInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const loadTimeEntries = async () => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await getApi().getAll();
|
||||
const entries = await solidtimeApi.getTimeEntries();
|
||||
setTimeEntries(entries);
|
||||
|
||||
// Handle different response formats
|
||||
let entries: TimeEntry[] = [];
|
||||
if (response && response.time_entries) {
|
||||
entries = response.time_entries;
|
||||
} else if (response && Array.isArray(response)) {
|
||||
entries = response;
|
||||
const active = await solidtimeApi.getActiveTimeEntry();
|
||||
setCurrentRunningEntry(active);
|
||||
|
||||
if (active) {
|
||||
const start = new Date(active.start).getTime();
|
||||
const now = Date.now();
|
||||
setElapsedSeconds(Math.floor((now - start) / 1000));
|
||||
} else {
|
||||
console.warn('Unexpected response format:', response);
|
||||
entries = [];
|
||||
setElapsedSeconds(0);
|
||||
}
|
||||
|
||||
setTimeEntries(entries);
|
||||
const stats = await solidtimeApi.getTimeEntriesStats();
|
||||
setStats(stats);
|
||||
} catch (err) {
|
||||
console.error('Failed to load time entries:', err);
|
||||
setTimeEntries([]); // Ensure empty array on error
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate today's statistics including real-time running timer
|
||||
const getTodayStats = () => {
|
||||
const entries = timeEntries() || [];
|
||||
const today = new Date().toDateString();
|
||||
const todayEntries = entries.filter(entry =>
|
||||
new Date(entry.start_time).toDateString() === today
|
||||
);
|
||||
|
||||
// Start with completed entries
|
||||
let totalSeconds = todayEntries.reduce((sum, entry) =>
|
||||
sum + (entry.duration || 0), 0
|
||||
);
|
||||
|
||||
let billableSeconds = todayEntries.reduce((sum, entry) =>
|
||||
sum + (entry.duration || 0), 0
|
||||
);
|
||||
|
||||
let totalBillableAmount = todayEntries.reduce((sum, entry) => {
|
||||
if (entry.duration && entry.hourly_rate && entry.billable) {
|
||||
return sum + (entry.duration / 3600 * entry.hourly_rate);
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
|
||||
// Add real-time data from currently running timer
|
||||
const runningEntry = currentRunningEntry();
|
||||
if (runningEntry && new Date(runningEntry.start_time).toDateString() === today) {
|
||||
const elapsed = currentElapsedSeconds();
|
||||
totalSeconds += elapsed;
|
||||
|
||||
if (runningEntry.billable) {
|
||||
billableSeconds += elapsed;
|
||||
if (runningEntry.hourly_rate) {
|
||||
totalBillableAmount += (elapsed / 3600 * runningEntry.hourly_rate);
|
||||
}
|
||||
}
|
||||
const startTimer = async () => {
|
||||
try {
|
||||
const entry = await solidtimeApi.startTimeEntry({
|
||||
description: 'Working on Trackeep',
|
||||
});
|
||||
setCurrentRunningEntry(entry);
|
||||
setElapsedSeconds(0);
|
||||
haptics.impact();
|
||||
loadData();
|
||||
} catch (err) {
|
||||
console.error('Failed to start timer:', err);
|
||||
}
|
||||
|
||||
const runningCount = todayEntries.filter(entry => entry.is_running).length +
|
||||
(runningEntry ? 1 : 0);
|
||||
|
||||
return {
|
||||
totalSeconds,
|
||||
totalEntries: todayEntries.length + (runningEntry ? 1 : 0),
|
||||
billableSeconds,
|
||||
totalBillableAmount,
|
||||
runningCount
|
||||
};
|
||||
};
|
||||
|
||||
const formatTime = (seconds: number): string => {
|
||||
const stopTimer = async () => {
|
||||
if (!currentRunningEntry()) return;
|
||||
try {
|
||||
await solidtimeApi.stopTimeEntry(currentRunningEntry().id);
|
||||
setCurrentRunningEntry(null);
|
||||
setElapsedSeconds(0);
|
||||
haptics.selection();
|
||||
loadData();
|
||||
} catch (err) {
|
||||
console.error('Failed to stop timer:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
return `${hours}h ${minutes}m`;
|
||||
const secs = seconds % 60;
|
||||
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const formatAmount = (amount: number): string => {
|
||||
return `$${amount.toFixed(2)}`;
|
||||
const formatDuration = (start: string, end?: string) => {
|
||||
const startTime = new Date(start).getTime();
|
||||
const endTime = end ? new Date(end).getTime() : Date.now();
|
||||
const diff = Math.floor((endTime - startTime) / 1000);
|
||||
return formatTime(diff);
|
||||
};
|
||||
|
||||
const handleTimeEntryCreated = (newEntry: TimeEntry) => {
|
||||
setTimeEntries(prev => [newEntry, ...prev]);
|
||||
setRefreshTrigger(prev => prev + 1);
|
||||
haptics.success(); // Success feedback for time entry creation
|
||||
};
|
||||
|
||||
// Handle real-time timer updates
|
||||
const handleTimerUpdate = (entry: TimeEntry | null, elapsedSeconds: number) => {
|
||||
setCurrentRunningEntry(entry);
|
||||
setCurrentElapsedSeconds(elapsedSeconds);
|
||||
};
|
||||
|
||||
// Load time entries on mount and when refresh trigger changes
|
||||
onMount(() => {
|
||||
loadTimeEntries();
|
||||
loadData();
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (refreshTrigger() > 0) {
|
||||
loadTimeEntries();
|
||||
if (currentRunningEntry()) {
|
||||
timerInterval = setInterval(() => {
|
||||
setElapsedSeconds(s => s + 1);
|
||||
}, 1000);
|
||||
} else {
|
||||
if (timerInterval) {
|
||||
clearInterval(timerInterval);
|
||||
timerInterval = null;
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
if (timerInterval) clearInterval(timerInterval);
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="p-6 mt-4 pb-32 max-w-5xl mx-auto space-y-6">
|
||||
{/* Simple loading indicator */}
|
||||
{loading() && (
|
||||
<div class="text-center text-sm text-muted-foreground py-2">
|
||||
Loading time entries...
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Timer Component */}
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<Timer
|
||||
onTimeEntryCreated={handleTimeEntryCreated}
|
||||
onTimerUpdate={handleTimerUpdate}
|
||||
/>
|
||||
<h1 class="text-3xl font-bold tracking-tight">Time Tracking</h1>
|
||||
<p class="text-muted-foreground mt-1">
|
||||
Track your time with solidtime.io integration
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time Stats - Standardized Design */}
|
||||
<div class="border rounded-lg p-4">
|
||||
<h2 class="text-lg font-semibold mb-4">Today's Overview</h2>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="bg-muted flex items-center justify-center p-2 rounded-lg">
|
||||
<IconClock class="size-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-light">{formatTime(getTodayStats().totalSeconds)}</p>
|
||||
<p class="text-sm text-muted-foreground">Total Time Today</p>
|
||||
</div>
|
||||
<Card class="p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<IconClock class="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="bg-muted flex items-center justify-center p-2 rounded-lg">
|
||||
<IconActivity class="size-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-light">{getTodayStats().totalEntries}</p>
|
||||
<p class="text-sm text-muted-foreground">Entries Today</p>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">
|
||||
{currentRunningEntry() ? 'Timer Running' : 'Ready to Track'}
|
||||
</h2>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{currentRunningEntry()
|
||||
? currentRunningEntry().description || 'No description'
|
||||
: 'Start tracking your time'}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="bg-muted flex items-center justify-center p-2 rounded-lg">
|
||||
<IconCurrencyDollar class="size-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-light">{formatAmount(getTodayStats().totalBillableAmount)}</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Billable Today
|
||||
{currentRunningEntry() && currentRunningEntry()?.billable && (
|
||||
<span class="ml-1 text-green-600 dark:text-green-400">
|
||||
● Live
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-4xl font-bold tabular-nums">
|
||||
{formatTime(elapsedSeconds())}
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="bg-muted flex items-center justify-center p-2 rounded-lg">
|
||||
<IconActivity class="size-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-light">{getTodayStats().runningCount}</p>
|
||||
<p class="text-sm text-muted-foreground">Running Timers</p>
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{currentRunningEntry() ? 'Running' : 'Stopped'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 flex gap-3">
|
||||
{!currentRunningEntry() ? (
|
||||
<Button onClick={startTimer} class="gap-2">
|
||||
<IconActivity class="w-4 h-4" />
|
||||
Start Timer
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={stopTimer} variant="destructive" class="gap-2">
|
||||
<IconClock class="w-4 h-4" />
|
||||
Stop Timer
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<Card class="p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<IconClock class="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-muted-foreground">Total Today</p>
|
||||
<p class="text-xl font-bold">{formatTime(stats().total)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-full bg-green-500/10 flex items-center justify-center">
|
||||
<IconCurrencyDollar class="w-5 h-5 text-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-muted-foreground">Billable</p>
|
||||
<p class="text-xl font-bold">{formatTime(stats().billable)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-full bg-orange-500/10 flex items-center justify-center">
|
||||
<IconActivity class="w-5 h-5 text-orange-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-muted-foreground">Non-Billable</p>
|
||||
<p class="text-xl font-bold">{formatTime(stats().nonBillable)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Time Entries List */}
|
||||
<div>
|
||||
<TimeEntriesList refreshTrigger={refreshTrigger()} />
|
||||
</div>
|
||||
<Card class="p-6">
|
||||
<h3 class="text-lg font-semibold mb-4">Recent Time Entries</h3>
|
||||
{loading() ? (
|
||||
<LoadingState message="Loading entries..." center />
|
||||
) : timeEntries().length === 0 ? (
|
||||
<div class="text-center py-8 text-muted-foreground">
|
||||
<IconClock class="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p>No time entries yet</p>
|
||||
<p class="text-sm">Start tracking your time to see entries here</p>
|
||||
</div>
|
||||
) : (
|
||||
<div class="space-y-3">
|
||||
{timeEntries().map((entry) => (
|
||||
<div class="flex items-center justify-between p-3 rounded-lg border hover:bg-accent/50 transition-colors">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-2 h-2 rounded-full bg-primary" />
|
||||
<div>
|
||||
<p class="font-medium">{entry.description || 'Untitled'}</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{new Date(entry.start).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<Badge variant={entry.billable ? 'default' : 'secondary'}>
|
||||
{entry.billable ? 'Billable' : 'Non-Billable'}
|
||||
</Badge>
|
||||
<span class="font-mono text-sm">
|
||||
{formatDuration(entry.start, entry.end)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createSignal, createEffect, Show, For } from 'solid-js';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { ConfirmModal } from '@/components/ui/ConfirmModal';
|
||||
import { toast } from '@/components/ui/Toast';
|
||||
import { CheckCircle, AlertCircle, Shield, Key, Globe, Clock, Users, Settings } from 'lucide-solid';
|
||||
import { getApiV1BaseUrl } from '@/lib/api-url';
|
||||
@@ -55,12 +56,19 @@ const BrowserExtensionSettings = () => {
|
||||
'files:write'
|
||||
]);
|
||||
const [activeTab, setActiveTab] = createSignal<'overview' | 'api-keys' | 'extensions' | 'examples'>('api-keys');
|
||||
const [showConfirmModal, setShowConfirmModal] = createSignal(false);
|
||||
const [confirmModalConfig, setConfirmModalConfig] = createSignal({
|
||||
title: 'Confirm Action',
|
||||
message: 'Are you sure?',
|
||||
onConfirm: () => {}
|
||||
});
|
||||
const [confirmTarget, setConfirmTarget] = createSignal<{type: 'key' | 'extension', id: number | string} | null>(null);
|
||||
|
||||
const quickStartGuides: QuickStartGuide[] = [
|
||||
{
|
||||
title: 'Generate API Key',
|
||||
description: 'Create a secure API key for your browser extension',
|
||||
icon: <Key class="w-5 h-5 text-blue-600" />,
|
||||
icon: <Key class="w-5 h-5 text-primary" />,
|
||||
steps: [
|
||||
'Go to Settings → Browser Extension',
|
||||
'Click "Generate New Key"',
|
||||
@@ -71,7 +79,7 @@ const BrowserExtensionSettings = () => {
|
||||
{
|
||||
title: 'Configure Extension',
|
||||
description: 'Set up your browser extension with the API key',
|
||||
icon: <Settings class="w-5 h-5 text-green-600" />,
|
||||
icon: <Settings class="w-5 h-5 text-primary" />,
|
||||
steps: [
|
||||
'Install the Trackeep browser extension',
|
||||
'Open extension options',
|
||||
@@ -83,7 +91,7 @@ const BrowserExtensionSettings = () => {
|
||||
{
|
||||
title: 'Security Best Practices',
|
||||
description: 'Keep your API keys secure and monitor usage',
|
||||
icon: <Shield class="w-5 h-5 text-purple-600" />,
|
||||
icon: <Shield class="w-5 h-5 text-primary" />,
|
||||
steps: [
|
||||
'Use unique names for each key',
|
||||
'Set expiration dates for temporary access',
|
||||
@@ -258,12 +266,20 @@ curl -X POST \\\n -H "Authorization: Bearer tk_your_api_key_here" \\\n -H "Con
|
||||
};
|
||||
|
||||
const revokeAPIKey = async (keyId: number) => {
|
||||
if (!confirm('Are you sure you want to revoke this API key? This action cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
setConfirmTarget({type: 'key', id: keyId});
|
||||
setConfirmModalConfig({
|
||||
title: 'Revoke API Key',
|
||||
message: 'Are you sure you want to revoke this API key? This action cannot be undone.',
|
||||
onConfirm: confirmRevokeAPIKey
|
||||
});
|
||||
setShowConfirmModal(true);
|
||||
};
|
||||
|
||||
const confirmRevokeAPIKey = async () => {
|
||||
const target = confirmTarget();
|
||||
if (!target || target.type !== 'key') return;
|
||||
try {
|
||||
const response = await fetch(`${apiBaseUrl}/browser-extension/api-keys/${keyId}`, {
|
||||
const response = await fetch(`${apiBaseUrl}/browser-extension/api-keys/${target.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
@@ -279,16 +295,27 @@ curl -X POST \\\n -H "Authorization: Bearer tk_your_api_key_here" \\\n -H "Con
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Error revoking API key');
|
||||
} finally {
|
||||
setShowConfirmModal(false);
|
||||
setConfirmTarget(null);
|
||||
}
|
||||
};
|
||||
|
||||
const revokeExtension = async (extensionId: string) => {
|
||||
if (!confirm('Are you sure you want to revoke this extension? This action cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
setConfirmTarget({type: 'extension', id: extensionId});
|
||||
setConfirmModalConfig({
|
||||
title: 'Revoke Extension',
|
||||
message: 'Are you sure you want to revoke this extension? This action cannot be undone.',
|
||||
onConfirm: confirmRevokeExtension
|
||||
});
|
||||
setShowConfirmModal(true);
|
||||
};
|
||||
|
||||
const confirmRevokeExtension = async () => {
|
||||
const target = confirmTarget();
|
||||
if (!target || target.type !== 'extension') return;
|
||||
try {
|
||||
const response = await fetch(`${apiBaseUrl}/browser-extension/extensions/${extensionId}`, {
|
||||
const response = await fetch(`${apiBaseUrl}/browser-extension/extensions/${target.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
@@ -304,6 +331,9 @@ curl -X POST \\\n -H "Authorization: Bearer tk_your_api_key_here" \\\n -H "Con
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Error revoking extension');
|
||||
} finally {
|
||||
setShowConfirmModal(false);
|
||||
setConfirmTarget(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -324,55 +354,55 @@ curl -X POST \\\n -H "Authorization: Bearer tk_your_api_key_here" \\\n -H "Con
|
||||
return (
|
||||
<div class="p-6">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-2">Browser Extension Settings</h1>
|
||||
<p class="text-gray-600">Manage API keys and browser extensions for secure access to your Trackeep account.</p>
|
||||
<h1 class="text-2xl font-bold text-foreground mb-2">Browser Extension Settings</h1>
|
||||
<p class="text-muted-foreground">Manage API keys and browser extensions for secure access to your Trackeep account.</p>
|
||||
</div>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div class="border-b border-gray-200 mb-6">
|
||||
<div class="border-b border-border mb-6">
|
||||
<nav class="flex space-x-8">
|
||||
<button
|
||||
class={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||
class={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 transition-colors ${
|
||||
activeTab() === 'overview'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
|
||||
}`}
|
||||
onClick={() => setActiveTab('overview')}
|
||||
>
|
||||
<Globe class="w-4 h-4 mr-2" />
|
||||
<Globe class="w-4 h-4" />
|
||||
Overview
|
||||
</button>
|
||||
<button
|
||||
class={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||
class={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 transition-colors ${
|
||||
activeTab() === 'api-keys'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
|
||||
}`}
|
||||
onClick={() => setActiveTab('api-keys')}
|
||||
>
|
||||
<Key class="w-4 h-4 mr-2" />
|
||||
<Key class="w-4 h-4" />
|
||||
API Keys
|
||||
</button>
|
||||
<button
|
||||
class={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||
class={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 transition-colors ${
|
||||
activeTab() === 'extensions'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
|
||||
}`}
|
||||
onClick={() => setActiveTab('extensions')}
|
||||
>
|
||||
<Users class="w-4 h-4 mr-2" />
|
||||
<Users class="w-4 h-4" />
|
||||
Extensions
|
||||
</button>
|
||||
<button
|
||||
class={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||
class={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 transition-colors ${
|
||||
activeTab() === 'examples'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
|
||||
}`}
|
||||
onClick={() => setActiveTab('examples')}
|
||||
>
|
||||
<Shield class="w-4 h-4 mr-2" />
|
||||
<Shield class="w-4 h-4" />
|
||||
Examples
|
||||
</button>
|
||||
</nav>
|
||||
@@ -385,18 +415,18 @@ curl -X POST \\\n -H "Authorization: Bearer tk_your_api_key_here" \\\n -H "Con
|
||||
{guide => (
|
||||
<Card class="p-6">
|
||||
<div class="text-center mb-4">
|
||||
<div class="inline-flex items-center justify-center w-12 h-12 bg-blue-100 rounded-full mb-3">
|
||||
<div class="inline-flex items-center justify-center w-12 h-12 bg-primary/10 rounded-full mb-3">
|
||||
{guide.icon}
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900">{guide.title}</h3>
|
||||
<p class="text-gray-600 text-sm mb-4">{guide.description}</p>
|
||||
<h3 class="text-lg font-semibold text-foreground">{guide.title}</h3>
|
||||
<p class="text-muted-foreground text-sm mb-4">{guide.description}</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<For each={guide.steps}>
|
||||
{step => (
|
||||
<div class="flex items-center space-x-2">
|
||||
<CheckCircle class="w-4 h-4 text-green-500 flex-shrink-0" />
|
||||
<span class="text-sm text-gray-700">{step}</span>
|
||||
<span class="text-sm text-foreground">{step}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
@@ -408,8 +438,8 @@ curl -X POST \\\n -H "Authorization: Bearer tk_your_api_key_here" \\\n -H "Con
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card class="p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4 flex items-center">
|
||||
<AlertCircle class="w-5 h-5 text-orange-500 mr-2" />
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
|
||||
<AlertCircle class="w-5 h-5 text-orange-500" />
|
||||
Security Status
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
@@ -417,42 +447,42 @@ curl -X POST \\\n -H "Authorization: Bearer tk_your_api_key_here" \\\n -H "Con
|
||||
<div class="w-3 h-3 bg-green-500 rounded-full"></div>
|
||||
<div>
|
||||
<div class="font-medium text-green-700">API Keys Secure</div>
|
||||
<div class="text-sm text-gray-600">All keys using secure API key authentication</div>
|
||||
<div class="text-sm text-muted-foreground">All keys using secure API key authentication</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="w-3 h-3 bg-green-500 rounded-full"></div>
|
||||
<div>
|
||||
<div class="font-medium text-green-700">Extensions Registered</div>
|
||||
<div class="text-sm text-gray-600">{extensions().length} active extensions</div>
|
||||
<div class="text-sm text-muted-foreground">{extensions().length} active extensions</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="w-3 h-3 bg-yellow-500 rounded-full"></div>
|
||||
<div>
|
||||
<div class="font-medium text-yellow-700">Quick Setup Available</div>
|
||||
<div class="text-sm text-gray-600">Get started in under 5 minutes</div>
|
||||
<div class="text-sm text-muted-foreground">Get started in under 5 minutes</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card class="p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4 flex items-center">
|
||||
<Clock class="w-5 h-5 text-blue-500 mr-2" />
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
|
||||
<Clock class="w-5 h-5 text-primary" />
|
||||
Recent Activity
|
||||
</h3>
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm text-gray-600">
|
||||
<div class="font-medium text-gray-900">Last API key created:</div>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
<div class="font-medium text-foreground">Last API key created:</div>
|
||||
<div>{apiKeys().length > 0 ? new Date(apiKeys()[0].created_at).toLocaleDateString() : 'No keys created yet'}</div>
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">
|
||||
<div class="font-medium text-gray-900">Total API keys:</div>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
<div class="font-medium text-foreground">Total API keys:</div>
|
||||
<div>{apiKeys().length} active, {apiKeys().filter(k => !k.is_active).length} revoked</div>
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">
|
||||
<div class="font-medium text-gray-900">Extensions active:</div>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
<div class="font-medium text-foreground">Extensions active:</div>
|
||||
<div>{extensions().length} connected</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -464,18 +494,18 @@ curl -X POST \\\n -H "Authorization: Bearer tk_your_api_key_here" \\\n -H "Con
|
||||
<Show when={activeTab() === 'api-keys'}>
|
||||
<Card class="mb-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-semibold">API Keys</h2>
|
||||
<Button onClick={() => setShowCreateKey(true)} class="btn-primary">
|
||||
<h2 class="text-xl font-semibold text-foreground">API Keys</h2>
|
||||
<Button onClick={() => setShowCreateKey(true)}>
|
||||
Generate New Key
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Show when={showCreateKey()}>
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
|
||||
<h3 class="text-lg font-medium mb-4">Create New API Key</h3>
|
||||
<div class="bg-primary/5 border border-primary/20 rounded-lg p-4 mb-4">
|
||||
<h3 class="text-lg font-medium text-foreground mb-4">Create New API Key</h3>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Key Name</label>
|
||||
<label class="block text-sm font-medium text-foreground mb-2">Key Name</label>
|
||||
<Input
|
||||
value={newKeyName()}
|
||||
onInput={(e: InputEvent) => setNewKeyName((e.target as HTMLInputElement).value)}
|
||||
@@ -485,7 +515,7 @@ curl -X POST \\\n -H "Authorization: Bearer tk_your_api_key_here" \\\n -H "Con
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Permissions</label>
|
||||
<label class="block text-sm font-medium text-foreground mb-2">Permissions</label>
|
||||
<div class="space-y-2">
|
||||
<For each={availablePermissions}>
|
||||
{permission => (
|
||||
@@ -494,11 +524,11 @@ curl -X POST \\\n -H "Authorization: Bearer tk_your_api_key_here" \\\n -H "Con
|
||||
type="checkbox"
|
||||
checked={newKeyPermissions().includes(permission.id)}
|
||||
onChange={() => togglePermission(permission.id)}
|
||||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
class="rounded border-input text-primary focus:ring-primary"
|
||||
/>
|
||||
<div>
|
||||
<span class="font-medium">{permission.label}</span>
|
||||
<span class="text-sm text-gray-500">{permission.description}</span>
|
||||
<span class="font-medium text-foreground">{permission.label}</span>
|
||||
<span class="text-sm text-muted-foreground">{permission.description}</span>
|
||||
</div>
|
||||
</label>
|
||||
)}
|
||||
@@ -508,15 +538,14 @@ curl -X POST \\\n -H "Authorization: Bearer tk_your_api_key_here" \\\n -H "Con
|
||||
|
||||
<div class="flex space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowCreateKey(false)}
|
||||
class="btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={createAPIKey}
|
||||
disabled={loading()}
|
||||
class="btn-primary"
|
||||
>
|
||||
{loading() ? 'Creating...' : 'Create Key'}
|
||||
</Button>
|
||||
@@ -527,24 +556,24 @@ curl -X POST \\\n -H "Authorization: Bearer tk_your_api_key_here" \\\n -H "Con
|
||||
<div class="space-y-3">
|
||||
<For each={apiKeys()}>
|
||||
{key => (
|
||||
<div class="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<div class="bg-card border border-border rounded-lg p-4">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-medium">{key.name}</h3>
|
||||
<div class="text-sm text-gray-500 mb-2">
|
||||
<h3 class="text-lg font-medium text-foreground">{key.name}</h3>
|
||||
<div class="text-sm text-muted-foreground mb-2">
|
||||
Created: {new Date(key.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<For each={key.permissions}>
|
||||
{permission => (
|
||||
<span class="inline-block bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded">
|
||||
<span class="inline-block bg-primary/10 text-primary text-xs px-2 py-1 rounded">
|
||||
{permission}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
{key.expires_at && (
|
||||
<div class="text-sm text-orange-600">
|
||||
<div class="text-sm text-orange-500">
|
||||
Expires: {new Date(key.expires_at).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
@@ -556,8 +585,8 @@ curl -X POST \\\n -H "Authorization: Bearer tk_your_api_key_here" \\\n -H "Con
|
||||
{key.is_active ? 'Active' : 'Revoked'}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => revokeAPIKey(key.id)}
|
||||
class="btn-secondary btn-sm"
|
||||
disabled={!key.is_active}
|
||||
>
|
||||
Revoke
|
||||
@@ -572,51 +601,53 @@ curl -X POST \\\n -H "Authorization: Bearer tk_your_api_key_here" \\\n -H "Con
|
||||
</Show>
|
||||
|
||||
{/* Browser Extensions Section */}
|
||||
<Card>
|
||||
<div class="mb-4">
|
||||
<h2 class="text-xl font-semibold">Registered Extensions</h2>
|
||||
<p class="text-sm text-gray-600">Manage browser extensions that have access to your account.</p>
|
||||
</div>
|
||||
<Show when={activeTab() === 'extensions'}>
|
||||
<Card>
|
||||
<div class="mb-4">
|
||||
<h2 class="text-xl font-semibold text-foreground">Registered Extensions</h2>
|
||||
<p class="text-sm text-muted-foreground">Manage browser extensions that have access to your account.</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<For each={extensions()}>
|
||||
{extension => (
|
||||
<div class="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-medium">{extension.name}</h3>
|
||||
<div class="text-sm text-gray-500 mb-2">
|
||||
Extension ID: {extension.extension_id}
|
||||
</div>
|
||||
<div class="text-sm text-gray-500 mb-2">
|
||||
Registered: {new Date(extension.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
{extension.last_seen && (
|
||||
<div class="text-sm text-gray-500">
|
||||
Last seen: {new Date(extension.last_seen).toLocaleDateString()}
|
||||
<div class="space-y-3">
|
||||
<For each={extensions()}>
|
||||
{extension => (
|
||||
<div class="bg-card border border-border rounded-lg p-4">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-medium text-foreground">{extension.name}</h3>
|
||||
<div class="text-sm text-muted-foreground mb-2">
|
||||
Extension ID: {extension.extension_id}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<span class={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
extension.is_active ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{extension.is_active ? 'Active' : 'Revoked'}
|
||||
</span>
|
||||
<Button
|
||||
onClick={() => revokeExtension(extension.extension_id)}
|
||||
class="btn-secondary btn-sm"
|
||||
disabled={!extension.is_active}
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
<div class="text-sm text-muted-foreground mb-2">
|
||||
Registered: {new Date(extension.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
{extension.last_seen && (
|
||||
<div class="text-sm text-muted-foreground">
|
||||
Last seen: {new Date(extension.last_seen).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<span class={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
extension.is_active ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{extension.is_active ? 'Active' : 'Revoked'}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => revokeExtension(extension.extension_id)}
|
||||
disabled={!extension.is_active}
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Card>
|
||||
</Show>
|
||||
|
||||
{/* Examples Tab */}
|
||||
<Show when={activeTab() === 'examples'}>
|
||||
@@ -625,34 +656,33 @@ curl -X POST \\\n -H "Authorization: Bearer tk_your_api_key_here" \\\n -H "Con
|
||||
{example => (
|
||||
<Card class="p-6">
|
||||
<div class="mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2 flex items-center">
|
||||
<div class="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center mr-3">
|
||||
<span class="text-blue-600 font-mono text-xs font-bold">{example.language.toUpperCase()}</span>
|
||||
<h3 class="text-lg font-semibold text-foreground mb-2 flex items-center gap-3">
|
||||
<div class="w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center">
|
||||
<span class="text-primary font-mono text-xs font-bold">{example.language.toUpperCase()}</span>
|
||||
</div>
|
||||
{example.title}
|
||||
</h3>
|
||||
<p class="text-gray-600 text-sm mb-4">{example.description}</p>
|
||||
<p class="text-muted-foreground text-sm mb-4">{example.description}</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-900 rounded-lg p-4 overflow-x-auto">
|
||||
<pre class="text-green-400 text-sm">
|
||||
<div class="bg-muted rounded-lg p-4 overflow-x-auto">
|
||||
<pre class="text-foreground text-sm">
|
||||
<code>{example.code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(example.code);
|
||||
toast.success('Code copied to clipboard!');
|
||||
}}
|
||||
class="btn-secondary"
|
||||
>
|
||||
Copy Code
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => window.open(`https://your-trackeep.com/api/v1/browser-extension/validate`, '_blank')}
|
||||
class="btn-primary"
|
||||
onClick={() => window.open(`${apiBaseUrl}/browser-extension/validate`, '_blank')}
|
||||
>
|
||||
Test API
|
||||
</Button>
|
||||
@@ -662,6 +692,19 @@ curl -X POST \\\n -H "Authorization: Bearer tk_your_api_key_here" \\\n -H "Con
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={showConfirmModal()}
|
||||
onClose={() => {
|
||||
setShowConfirmModal(false);
|
||||
setConfirmTarget(null);
|
||||
}}
|
||||
onConfirm={() => confirmModalConfig().onConfirm()}
|
||||
title={confirmModalConfig().title}
|
||||
message={confirmModalConfig().message}
|
||||
confirmText="Revoke"
|
||||
type="danger"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user