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

This commit is contained in:
Tomas Dvorak
2026-06-10 10:41:31 +02:00
parent 4dfdd500b4
commit 43acbfab15
51 changed files with 1284 additions and 15385 deletions
+1 -1
View File
@@ -269,7 +269,7 @@ func AdminCreateUser(c *gin.Context) {
return return
} }
_ = ensureMessagingDefaults(db, user.ID)
user.Password = "" user.Password = ""
c.JSON(http.StatusCreated, gin.H{ c.JSON(http.StatusCreated, gin.H{
-811
View File
@@ -1,811 +0,0 @@
package handlers
import (
"encoding/json"
"fmt"
"net/http"
"os"
"time"
"github.com/gin-gonic/gin"
"github.com/trackeep/backend/models"
"github.com/trackeep/backend/services"
)
// SummarizeContentRequest represents a request to summarize content
type SummarizeContentRequest struct {
ContentType string `json:"content_type" binding:"required"` // "bookmark", "note", "file"
ContentID uint `json:"content_id" binding:"required"`
Provider string `json:"provider"` // "mistral", "longcat", "" for default
ModelType string `json:"model_type"` // "standard", "thinking", "upgraded_thinking"
Options struct {
Length string `json:"length"` // "short", "medium", "long"
Style string `json:"style"` // "bullet", "paragraph", "executive"
IncludeKey bool `json:"include_key"` // Include key points
} `json:"options"`
}
// GenerateTaskSuggestionsRequest represents a request for task suggestions
type GenerateTaskSuggestionsRequest struct {
Context string `json:"context"` // "calendar", "deadlines", "habits", "all"
Timeframe string `json:"timeframe"` // "today", "week", "month"
Limit int `json:"limit"` // Max number of suggestions
Provider string `json:"provider"` // "mistral", "longcat", "" for default
ModelType string `json:"model_type"` // "standard", "thinking", "upgraded_thinking"
}
// GenerateTagsRequest represents a request for tag suggestions
type GenerateTagsRequest struct {
ContentType string `json:"content_type" binding:"required"`
ContentID uint `json:"content_id" binding:"required"`
Content string `json:"content" binding:"required"`
ExistingTag string `json:"existing_tags"`
Provider string `json:"provider"` // "mistral", "longcat", "" for default
ModelType string `json:"model_type"` // "standard", "thinking", "upgraded_thinking"
}
// GenerateContentRequest represents a request for content generation
type GenerateContentRequest struct {
Prompt string `json:"prompt" binding:"required"`
ContentType string `json:"content_type" binding:"required"`
Context string `json:"context"`
Temperature float64 `json:"temperature"`
MaxLength int `json:"max_length"`
Provider string `json:"provider"` // "mistral", "longcat", "" for default
ModelType string `json:"model_type"` // "standard", "thinking", "upgraded_thinking"
}
// SummarizeContent generates AI summary for content
func SummarizeContent(c *gin.Context) {
userID := c.GetUint("user_id")
var req SummarizeContentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Get content based on type
var content string
var title string
switch req.ContentType {
case "bookmark":
var bookmark models.Bookmark
if err := models.DB.Where("id = ? AND user_id = ?", req.ContentID, userID).First(&bookmark).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Content not found"})
return
}
content = bookmark.Content
title = bookmark.Title
case "note":
var note models.Note
if err := models.DB.Where("id = ? AND user_id = ?", req.ContentID, userID).First(&note).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Content not found"})
return
}
content = note.Content
title = note.Title
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported content type"})
return
}
if content == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "No content to summarize"})
return
}
// Check if summary already exists
var existingSummary models.AISummary
if err := models.DB.Where("user_id = ? AND content_type = ? AND content_id = ?", userID, req.ContentType, req.ContentID).First(&existingSummary).Error; err == nil {
c.JSON(http.StatusOK, existingSummary)
return
}
// Generate summary using AI
summary, err := generateAISummary(content, title, req.Options, req.Provider, req.ModelType)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate summary: " + err.Error()})
return
}
// Save summary
aiSummary := models.AISummary{
UserID: userID,
ContentType: req.ContentType,
ContentID: req.ContentID,
Title: summary.Title,
Summary: summary.Summary,
KeyPoints: summary.KeyPoints,
Tags: summary.Tags,
ReadTime: summary.ReadTime,
Complexity: summary.Complexity,
ModelUsed: getProviderModel(req.Provider),
Confidence: summary.Confidence,
LastAnalyzed: time.Now(),
}
if err := models.DB.Create(&aiSummary).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save summary"})
return
}
c.JSON(http.StatusOK, aiSummary)
}
// GetTaskSuggestions generates AI task suggestions
func GetTaskSuggestions(c *gin.Context) {
userID := c.GetUint("user_id")
var req GenerateTaskSuggestionsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Build context from user data
contextData, err := buildTaskContext(userID, req.Context, req.Timeframe)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to build context"})
return
}
// Generate suggestions
suggestions, err := generateTaskSuggestions(contextData, req.Limit, req.Provider, req.ModelType)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate suggestions: " + err.Error()})
return
}
// Save suggestions
var aiSuggestions []models.AITaskSuggestion
for _, suggestion := range suggestions {
aiSuggestion := models.AITaskSuggestion{
UserID: userID,
Title: suggestion.Title,
Description: suggestion.Description,
Priority: suggestion.Priority,
Category: suggestion.Category,
Reasoning: suggestion.Reasoning,
ContextType: req.Context,
ContextData: suggestion.ContextData,
Deadline: suggestion.Deadline,
EstimatedTime: suggestion.EstimatedTime,
ModelUsed: getProviderModel(req.Provider),
Confidence: suggestion.Confidence,
}
models.DB.Create(&aiSuggestion)
aiSuggestions = append(aiSuggestions, aiSuggestion)
}
c.JSON(http.StatusOK, aiSuggestions)
}
// GenerateTagSuggestions generates AI tag suggestions
func GenerateTagSuggestions(c *gin.Context) {
userID := c.GetUint("user_id")
var req GenerateTagsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Generate tags
tags, err := generateTagSuggestions(req.Content, req.ExistingTag, req.Provider, req.ModelType)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate tags: " + err.Error()})
return
}
// Save suggestion
tagSuggestion := models.AITagSuggestion{
UserID: userID,
ContentType: req.ContentType,
ContentID: req.ContentID,
SuggestedTags: tags.Suggested,
ExistingTags: req.ExistingTag,
Relevance: tags.Relevance,
ModelUsed: getProviderModel(req.Provider),
Confidence: tags.Confidence,
}
if err := models.DB.Create(&tagSuggestion).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save tag suggestion"})
return
}
c.JSON(http.StatusOK, tagSuggestion)
}
// GenerateContent generates AI content
func GenerateContent(c *gin.Context) {
userID := c.GetUint("user_id")
var req GenerateContentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Generate content
content, err := generateAIContent(req.Prompt, req.ContentType, req.Context, req.Temperature, req.MaxLength, req.Provider, req.ModelType)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate content: " + err.Error()})
return
}
// Save generation
aiContent := models.AIContentGeneration{
UserID: userID,
Prompt: req.Prompt,
ContentType: req.ContentType,
Context: req.Context,
Title: content.Title,
Content: content.Content,
WordCount: content.WordCount,
ReadTime: content.ReadTime,
ModelUsed: getProviderModel(req.Provider),
ProcessingMs: content.ProcessingMs,
TokenCount: content.TokenCount,
Confidence: content.Confidence,
Temperature: req.Temperature,
}
if err := models.DB.Create(&aiContent).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save content"})
return
}
c.JSON(http.StatusOK, aiContent)
}
// GetAIProviders returns available AI providers
func GetAIProviders(c *gin.Context) {
providers := services.GetAvailableProviders()
providerInfo := make([]map[string]interface{}, 0)
for _, provider := range providers {
info := map[string]interface{}{
"id": string(provider),
"name": getProviderDisplayName(provider),
}
// Add model info
switch provider {
case services.ProviderMistral:
standardModel := os.Getenv("MISTRAL_MODEL")
thinkingModel := os.Getenv("MISTRAL_MODEL_THINKING")
info["models"] = []map[string]string{
{"id": "standard", "name": standardModel, "type": "Standard"},
{"id": "thinking", "name": thinkingModel, "type": "Thinking"},
}
info["description"] = "Mistral AI - Fast and efficient European AI"
info["icon"] = "🇪🇺"
case services.ProviderLongCat:
standardModel := os.Getenv("LONGCAT_MODEL")
thinkingModel := os.Getenv("LONGCAT_MODEL_THINKING")
upgradedModel := os.Getenv("LONGCAT_MODEL_THINKING_UPGRADED")
models := []map[string]string{
{"id": "standard", "name": standardModel, "type": "Standard"},
{"id": "thinking", "name": thinkingModel, "type": "Thinking"},
}
if upgradedModel != "" {
models = append(models, map[string]string{"id": "upgraded_thinking", "name": upgradedModel, "type": "Upgraded Thinking"})
}
info["models"] = models
info["description"] = "LongCat AI - High-performance AI models"
info["icon"] = "🐱"
case services.ProviderGrok:
standardModel := os.Getenv("GROK_MODEL")
thinkingModel := os.Getenv("GROK_MODEL_THINKING")
models := []map[string]string{
{"id": "standard", "name": standardModel, "type": "Standard"},
}
if thinkingModel != "" && thinkingModel != standardModel {
models = append(models, map[string]string{"id": "thinking", "name": thinkingModel, "type": "Thinking"})
}
info["models"] = models
info["description"] = "Grok AI - Real-time information from X"
info["icon"] = "🐦"
case services.ProviderDeepSeek:
standardModel := os.Getenv("DEEPSEEK_MODEL")
thinkingModel := os.Getenv("DEEPSEEK_MODEL_THINKING")
models := []map[string]string{
{"id": "standard", "name": standardModel, "type": "Standard"},
}
if thinkingModel != "" && thinkingModel != standardModel {
models = append(models, map[string]string{"id": "thinking", "name": thinkingModel, "type": "Reasoning"})
}
info["models"] = models
info["description"] = "DeepSeek - Advanced reasoning AI"
info["icon"] = "🔍"
case services.ProviderOllama:
standardModel := os.Getenv("OLLAMA_MODEL")
thinkingModel := os.Getenv("OLLAMA_MODEL_THINKING")
models := []map[string]string{
{"id": "standard", "name": standardModel, "type": "Standard"},
}
if thinkingModel != "" && thinkingModel != standardModel {
models = append(models, map[string]string{"id": "thinking", "name": thinkingModel, "type": "Local"})
}
info["models"] = models
info["description"] = "Ollama - Local AI models"
info["icon"] = "🦙"
case services.ProviderOpenRouter:
standardModel := os.Getenv("OPENROUTER_MODEL")
thinkingModel := os.Getenv("OPENROUTER_MODEL_THINKING")
models := []map[string]string{
{"id": "standard", "name": standardModel, "type": "Standard"},
}
if thinkingModel != "" && thinkingModel != standardModel {
models = append(models, map[string]string{"id": "thinking", "name": thinkingModel, "type": "Thinking"})
}
info["models"] = models
info["description"] = "OpenRouter - Unified access to many models"
info["icon"] = "🌀"
}
providerInfo = append(providerInfo, info)
}
c.JSON(http.StatusOK, gin.H{"providers": providerInfo})
}
// Helper function to get display name for provider
func getProviderDisplayName(provider services.AIProvider) string {
switch provider {
case services.ProviderMistral:
return "Mistral AI"
case services.ProviderLongCat:
return "LongCat AI"
case services.ProviderGrok:
return "Grok AI"
case services.ProviderDeepSeek:
return "DeepSeek"
case services.ProviderOllama:
return "Ollama"
case services.ProviderOpenRouter:
return "OpenRouter"
default:
return string(provider)
}
}
// GetAISummaries retrieves AI summaries for user
func GetAISummaries(c *gin.Context) {
userID := c.GetUint("user_id")
var summaries []models.AISummary
models.DB.Where("user_id = ?", userID).Order("created_at desc").Find(&summaries)
c.JSON(http.StatusOK, summaries)
}
// GetTaskSuggestions retrieves task suggestions for user
func GetTaskSuggestionsList(c *gin.Context) {
userID := c.GetUint("user_id")
var suggestions []models.AITaskSuggestion
models.DB.Where("user_id = ? AND accepted = false AND dismissed = false", userID).Order("created_at desc").Find(&suggestions)
c.JSON(http.StatusOK, suggestions)
}
// AcceptTaskSuggestion accepts a task suggestion
func AcceptTaskSuggestion(c *gin.Context) {
userID := c.GetUint("user_id")
suggestionID := c.Param("id")
var suggestion models.AITaskSuggestion
if err := models.DB.Where("id = ? AND user_id = ?", suggestionID, userID).First(&suggestion).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Suggestion not found"})
return
}
// Create actual task
task := models.Task{
UserID: userID,
Title: suggestion.Title,
Description: suggestion.Description,
Priority: models.TaskPriority(suggestion.Priority),
Status: models.TaskStatusPending,
DueDate: suggestion.Deadline,
}
if err := models.DB.Create(&task).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create task"})
return
}
// Mark suggestion as accepted
suggestion.Accepted = true
models.DB.Save(&suggestion)
c.JSON(http.StatusOK, gin.H{"message": "Task created successfully", "task_id": task.ID})
}
// DismissTaskSuggestion dismisses a task suggestion
func DismissTaskSuggestion(c *gin.Context) {
userID := c.GetUint("user_id")
suggestionID := c.Param("id")
var suggestion models.AITaskSuggestion
if err := models.DB.Where("id = ? AND user_id = ?", suggestionID, userID).First(&suggestion).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Suggestion not found"})
return
}
suggestion.Dismissed = true
models.DB.Save(&suggestion)
c.JSON(http.StatusOK, gin.H{"message": "Suggestion dismissed"})
}
// Helper structs for AI responses
type AISummaryResponse struct {
Title string `json:"title"`
Summary string `json:"summary"`
KeyPoints string `json:"key_points"`
Tags string `json:"tags"`
ReadTime int `json:"read_time"`
Complexity string `json:"complexity"`
Confidence float64 `json:"confidence"`
}
type TaskSuggestionResponse struct {
Title string `json:"title"`
Description string `json:"description"`
Priority string `json:"priority"`
Category string `json:"category"`
Reasoning string `json:"reasoning"`
ContextData string `json:"context_data"`
Deadline *time.Time `json:"deadline"`
EstimatedTime int `json:"estimated_time"`
Confidence float64 `json:"confidence"`
}
type TagSuggestionResponse struct {
Suggested string `json:"suggested"`
Relevance float64 `json:"relevance"`
Confidence float64 `json:"confidence"`
}
type ContentGenerationResponse struct {
Title string `json:"title"`
Content string `json:"content"`
WordCount int `json:"word_count"`
ReadTime int `json:"read_time"`
ProcessingMs int64 `json:"processing_ms"`
TokenCount int `json:"token_count"`
Confidence float64 `json:"confidence"`
}
// AI generation functions (simplified - would call actual AI models)
func generateAISummary(content, title string, options struct {
Length string `json:"length"`
Style string `json:"style"`
IncludeKey bool `json:"include_key"`
}, provider string, modelType string) (*AISummaryResponse, error) {
// Build prompt for summarization
prompt := fmt.Sprintf(`Please summarize the following content:
Title: %s
Content: %s
Length: %s
Style: %s
Include key points: %t
Provide a JSON response with:
- title: Brief title
- summary: Main summary
- key_points: Array of key points (if requested)
- tags: Array of relevant tags
- read_time: Estimated reading time in minutes
- complexity: "low", "medium", or "high"
- confidence: Confidence score 0-1`, title, content, options.Length, options.Style, options.IncludeKey)
messages := []services.Message{
{Role: "system", Content: "You are an expert content summarizer. Always respond with valid JSON."},
{Role: "user", Content: prompt},
}
// Determine provider
aiProvider := services.ProviderMistral // default
if provider == "longcat" {
aiProvider = services.ProviderLongCat
}
aiService := services.NewAIService(aiProvider)
req := services.AIRequest{
Messages: messages,
MaxTokens: 2000,
Temperature: 0.3,
ModelType: modelType,
}
var resp *services.AIResponse
var err error
// Choose the appropriate method based on model type
switch req.ModelType {
case "thinking":
resp, err = aiService.ChatCompletionWithThinking(req)
case "upgraded_thinking":
resp, err = aiService.ChatCompletionWithUpgradedThinking(req)
default:
resp, err = aiService.ChatCompletion(req)
}
if err != nil {
return nil, err
}
// Parse the response content properly for thinking models
actualContent := services.ParseThinkingResponse(resp, aiProvider, modelType)
var summary AISummaryResponse
if err := json.Unmarshal([]byte(actualContent), &summary); err != nil {
return nil, err
}
return &summary, nil
}
func generateTaskSuggestions(contextData map[string]interface{}, limit int, provider string, modelType string) ([]TaskSuggestionResponse, error) {
// Build prompt for task suggestions
prompt := fmt.Sprintf(`Based on the following user context, suggest %d tasks:
Context: %+v
Provide a JSON array of task objects with:
- title: Task title
- description: Task description
- priority: "low", "medium", "high", "urgent"
- category: Task category
- reasoning: Why this task is suggested
- context_data: Additional context
- deadline: Suggested deadline (ISO date or null)
- estimated_time: Estimated time in minutes
- confidence: Confidence score 0-1`, limit, contextData)
messages := []services.Message{
{Role: "system", Content: "You are a productivity assistant. Always respond with valid JSON array."},
{Role: "user", Content: prompt},
}
// Determine provider
aiProvider := services.ProviderMistral // default
if provider == "longcat" {
aiProvider = services.ProviderLongCat
}
aiService := services.NewAIService(aiProvider)
req := services.AIRequest{
Messages: messages,
MaxTokens: 2000,
Temperature: 0.7,
ModelType: modelType,
}
var resp *services.AIResponse
var err error
// Choose the appropriate method based on model type
switch req.ModelType {
case "thinking":
resp, err = aiService.ChatCompletionWithThinking(req)
case "upgraded_thinking":
resp, err = aiService.ChatCompletionWithUpgradedThinking(req)
default:
resp, err = aiService.ChatCompletion(req)
}
if err != nil {
return nil, err
}
// Parse the response content properly for thinking models
actualContent := services.ParseThinkingResponse(resp, aiProvider, modelType)
var suggestions []TaskSuggestionResponse
if err := json.Unmarshal([]byte(actualContent), &suggestions); err != nil {
return nil, err
}
return suggestions, nil
}
func generateTagSuggestions(content, existingTags string, provider string, modelType string) (*TagSuggestionResponse, error) {
prompt := fmt.Sprintf(`Suggest relevant tags for this content:
Content: %s
Existing tags: %s
Provide JSON response with:
- suggested: Array of suggested tags
- relevance: Relevance score 0-1
- confidence: Confidence score 0-1`, content, existingTags)
messages := []services.Message{
{Role: "system", Content: "You are a tagging expert. Always respond with valid JSON."},
{Role: "user", Content: prompt},
}
// Determine provider
aiProvider := services.ProviderMistral // default
if provider == "longcat" {
aiProvider = services.ProviderLongCat
}
aiService := services.NewAIService(aiProvider)
req := services.AIRequest{
Messages: messages,
MaxTokens: 1000,
Temperature: 0.5,
ModelType: modelType,
}
var resp *services.AIResponse
var err error
// Choose the appropriate method based on model type
switch req.ModelType {
case "thinking":
resp, err = aiService.ChatCompletionWithThinking(req)
case "upgraded_thinking":
resp, err = aiService.ChatCompletionWithUpgradedThinking(req)
default:
resp, err = aiService.ChatCompletion(req)
}
if err != nil {
return nil, err
}
// Parse the response content properly for thinking models
actualContent := services.ParseThinkingResponse(resp, aiProvider, modelType)
var tags TagSuggestionResponse
if err := json.Unmarshal([]byte(actualContent), &tags); err != nil {
return nil, err
}
return &tags, nil
}
func generateAIContent(prompt, contentType, context string, temperature float64, maxLength int, provider string, modelType string) (*ContentGenerationResponse, error) {
fullPrompt := fmt.Sprintf(`Generate %s content based on this prompt:
%s
Additional context: %s
Max length: %d words
Provide JSON response with:
- title: Generated title
- content: Generated content
- word_count: Word count
- read_time: Estimated reading time in minutes
- confidence: Confidence score 0-1`, contentType, prompt, context, maxLength)
messages := []services.Message{
{Role: "system", Content: "You are a content generation expert. Always respond with valid JSON."},
{Role: "user", Content: fullPrompt},
}
// Determine provider
aiProvider := services.ProviderMistral // default
if provider == "longcat" {
aiProvider = services.ProviderLongCat
}
aiService := services.NewAIService(aiProvider)
// Adjust temperature if provided
temp := 0.7
if temperature > 0 {
temp = temperature
}
req := services.AIRequest{
Messages: messages,
MaxTokens: maxLength * 2, // Rough estimate
Temperature: temp,
ModelType: modelType,
}
var resp *services.AIResponse
var err error
// Choose the appropriate method based on model type
switch req.ModelType {
case "thinking":
resp, err = aiService.ChatCompletionWithThinking(req)
case "upgraded_thinking":
resp, err = aiService.ChatCompletionWithUpgradedThinking(req)
default:
resp, err = aiService.ChatCompletion(req)
}
if err != nil {
return nil, err
}
// Parse the response content properly for thinking models
actualContent := services.ParseThinkingResponse(resp, aiProvider, modelType)
var content ContentGenerationResponse
if err := json.Unmarshal([]byte(actualContent), &content); err != nil {
return nil, err
}
content.ProcessingMs = 0 // Would track actual processing time
content.TokenCount = resp.Usage.TotalTokens
return &content, nil
}
func buildTaskContext(userID uint, contextType, timeframe string) (map[string]interface{}, error) {
ctx := make(map[string]interface{})
// Get upcoming tasks
var tasks []models.Task
query := models.DB.Where("user_id = ?", userID)
if timeframe == "today" {
query = query.Where("deadline <= ?", time.Now().AddDate(0, 0, 1))
} else if timeframe == "week" {
query = query.Where("deadline <= ?", time.Now().AddDate(0, 0, 7))
}
query.Find(&tasks)
ctx["tasks"] = tasks
// Get calendar events
var events []models.CalendarEvent
models.DB.Where("user_id = ? AND start_time >= ?", userID, time.Now()).Find(&events)
ctx["events"] = events
return ctx, nil
}
// Helper function to get model name based on provider
func getProviderModel(provider string) string {
switch provider {
case "mistral":
return os.Getenv("MISTRAL_MODEL")
case "longcat":
return os.Getenv("LONGCAT_MODEL")
case "grok":
return os.Getenv("GROK_MODEL")
case "deepseek":
return os.Getenv("DEEPSEEK_MODEL")
case "ollama":
return os.Getenv("OLLAMA_MODEL")
case "openrouter":
return os.Getenv("OPENROUTER_MODEL")
default:
return os.Getenv("MISTRAL_MODEL")
}
}
-401
View File
@@ -1,401 +0,0 @@
package handlers
import (
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/trackeep/backend/models"
"github.com/trackeep/backend/services"
)
// AIRecommendationHandler handles AI recommendation endpoints
type AIRecommendationHandler struct {
db *gorm.DB
service *services.AIRecommendationService
}
// NewAIRecommendationHandler creates a new AI recommendation handler
func NewAIRecommendationHandler(db *gorm.DB) *AIRecommendationHandler {
return &AIRecommendationHandler{
db: db,
service: services.NewAIRecommendationService(db),
}
}
// GetRecommendations returns personalized recommendations for the user
func (h *AIRecommendationHandler) GetRecommendations(c *gin.Context) {
userID := c.GetUint("user_id")
// Parse query parameters
recommendationType := c.DefaultQuery("type", "mixed") // content, task, learning, connection, mixed
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "5"))
minConfidence, _ := strconv.ParseFloat(c.DefaultQuery("min_confidence", "0.0"), 64)
includeDismissed := c.DefaultQuery("include_dismissed", "false") == "true"
context := c.Query("context")
// Create recommendation request
req := services.RecommendationRequest{
UserID: userID,
RecommendationType: recommendationType,
Limit: limit,
MinConfidence: minConfidence,
IncludeDismissed: includeDismissed,
Context: context,
}
// Get recommendations
recommendations, err := h.service.GetRecommendations(req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get recommendations: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"recommendations": recommendations,
"count": len(recommendations),
"type": recommendationType,
})
}
// GetRecommendationStats returns recommendation statistics for the user
func (h *AIRecommendationHandler) GetRecommendationStats(c *gin.Context) {
userID := c.GetUint("user_id")
// Get user preferences
var prefs models.UserPreference
if err := h.db.Where("user_id = ?", userID).First(&prefs).Error; err != nil {
// Create default preferences
prefs = models.UserPreference{
UserID: userID,
EnableRecommendations: true,
MinConfidenceThreshold: 0.6,
MaxRecommendationsPerDay: 5,
MaxAgeHours: 168,
}
h.db.Create(&prefs)
}
// Get recommendation statistics
var stats struct {
TotalRecommendations int64 `json:"total_recommendations"`
ClickedCount int64 `json:"clicked_count"`
DismissedCount int64 `json:"dismissed_count"`
FeedbackCount int64 `json:"feedback_count"`
Types []struct {
Type string `json:"type"`
Count int64 `json:"count"`
} `json:"types"`
Categories []struct {
Category string `json:"category"`
Count int64 `json:"count"`
} `json:"categories"`
DailyStats []struct {
Date string `json:"date"`
Count int64 `json:"count"`
} `json:"daily_stats"`
}
// Total recommendations
h.db.Model(&models.AIRecommendation{}).Where("user_id = ?", userID).Count(&stats.TotalRecommendations)
// Clicked and dismissed counts
h.db.Model(&models.AIRecommendation{}).Where("user_id = ? AND clicked = ?", userID, true).Count(&stats.ClickedCount)
h.db.Model(&models.AIRecommendation{}).Where("user_id = ? AND dismissed = ?", userID, true).Count(&stats.DismissedCount)
h.db.Model(&models.AIRecommendation{}).Where("user_id = ? AND feedback != ''", userID).Count(&stats.FeedbackCount)
// Recommendations by type
h.db.Model(&models.AIRecommendation{}).
Select("recommendation_type as type, COUNT(*) as count").
Where("user_id = ?", userID).
Group("recommendation_type").
Scan(&stats.Types)
// Recommendations by category
h.db.Model(&models.AIRecommendation{}).
Select("category as category, COUNT(*) as count").
Where("user_id = ? AND category != ''", userID).
Group("category").
Order("count DESC").
Limit(10).
Scan(&stats.Categories)
// Daily stats for last 30 days
h.db.Model(&models.AIRecommendation{}).
Select("DATE(created_at) as date, COUNT(*) as count").
Where("user_id = ? AND created_at >= NOW() - INTERVAL '30 days'", userID).
Group("DATE(created_at)").
Order("date ASC").
Scan(&stats.DailyStats)
c.JSON(http.StatusOK, gin.H{
"stats": stats,
"preferences": prefs,
})
}
// UpdatePreferences updates user recommendation preferences
func (h *AIRecommendationHandler) UpdatePreferences(c *gin.Context) {
userID := c.GetUint("user_id")
var req struct {
EnableRecommendations bool `json:"enable_recommendations"`
ContentRecommendations bool `json:"content_recommendations"`
TaskRecommendations bool `json:"task_recommendations"`
LearningRecommendations bool `json:"learning_recommendations"`
ConnectionRecommendations bool `json:"connection_recommendations"`
MaxRecommendationsPerDay int `json:"max_recommendations_per_day"`
PreferredCategories []string `json:"preferred_categories"`
BlockedCategories []string `json:"blocked_categories"`
PreferredContentTypes []string `json:"preferred_content_types"`
MinConfidenceThreshold float64 `json:"min_confidence_threshold"`
MaxAgeHours int `json:"max_age_hours"`
EnablePersonalization bool `json:"enable_personalization"`
EnableFeedbackLearning bool `json:"enable_feedback_learning"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Update or create preferences
var prefs models.UserPreference
if err := h.db.Where("user_id = ?", userID).First(&prefs).Error; err != nil {
if err == gorm.ErrRecordNotFound {
prefs = models.UserPreference{UserID: userID}
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
}
// Update fields
prefs.EnableRecommendations = req.EnableRecommendations
prefs.ContentRecommendations = req.ContentRecommendations
prefs.TaskRecommendations = req.TaskRecommendations
prefs.LearningRecommendations = req.LearningRecommendations
prefs.ConnectionRecommendations = req.ConnectionRecommendations
prefs.MaxRecommendationsPerDay = req.MaxRecommendationsPerDay
prefs.PreferredCategories = req.PreferredCategories
prefs.BlockedCategories = req.BlockedCategories
prefs.PreferredContentTypes = req.PreferredContentTypes
prefs.MinConfidenceThreshold = req.MinConfidenceThreshold
prefs.MaxAgeHours = req.MaxAgeHours
prefs.EnablePersonalization = req.EnablePersonalization
prefs.EnableFeedbackLearning = req.EnableFeedbackLearning
if err := h.db.Save(&prefs).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update preferences"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Preferences updated successfully",
"preferences": prefs,
})
}
// RecordInteraction records user interaction with a recommendation
func (h *AIRecommendationHandler) RecordInteraction(c *gin.Context) {
userID := c.GetUint("user_id")
recommendationIDStr := c.Param("id")
recommendationID, err := strconv.ParseUint(recommendationIDStr, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid recommendation ID"})
return
}
var req struct {
InteractionType string `json:"interaction_type" binding:"required"` // click, dismiss, feedback, share
Context string `json:"context"` // dashboard, search, etc.
Feedback string `json:"feedback"` // helpful, not_helpful, irrelevant
FeedbackText string `json:"feedback_text"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Record the interaction
if err := h.service.RecordInteraction(userID, uint(recommendationID), req.InteractionType, req.Context); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to record interaction"})
return
}
// If feedback is provided, update the recommendation
if req.Feedback != "" {
var recommendation models.AIRecommendation
if err := h.db.Where("id = ? AND user_id = ?", uint(recommendationID), userID).First(&recommendation).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Recommendation not found"})
return
}
recommendation.Feedback = req.Feedback
recommendation.FeedbackText = req.FeedbackText
now := time.Now()
recommendation.FeedbackAt = &now
h.db.Save(&recommendation)
}
c.JSON(http.StatusOK, gin.H{"message": "Interaction recorded successfully"})
}
// GetRecommendationHistory returns user's recommendation history
func (h *AIRecommendationHandler) GetRecommendationHistory(c *gin.Context) {
userID := c.GetUint("user_id")
// Parse query parameters
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
recommendationType := c.Query("type")
status := c.Query("status") // clicked, dismissed, feedback
// Build query
query := h.db.Model(&models.AIRecommendation{}).Where("user_id = ?", userID)
if recommendationType != "" {
query = query.Where("recommendation_type = ?", recommendationType)
}
if status == "clicked" {
query = query.Where("clicked = ?", true)
} else if status == "dismissed" {
query = query.Where("dismissed = ?", true)
} else if status == "feedback" {
query = query.Where("feedback != ''", userID)
}
// Count total records
var total int64
query.Count(&total)
// Get paginated results
offset := (page - 1) * limit
var recommendations []models.AIRecommendation
query.Order("created_at DESC").Limit(limit).Offset(offset).Find(&recommendations)
c.JSON(http.StatusOK, gin.H{
"recommendations": recommendations,
"pagination": gin.H{
"page": page,
"limit": limit,
"total": total,
"pages": (total + int64(limit) - 1) / int64(limit),
},
})
}
// DeleteRecommendation deletes a recommendation
func (h *AIRecommendationHandler) DeleteRecommendation(c *gin.Context) {
userID := c.GetUint("user_id")
recommendationIDStr := c.Param("id")
recommendationID, err := strconv.ParseUint(recommendationIDStr, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid recommendation ID"})
return
}
// Delete the recommendation (only if it belongs to the user)
result := h.db.Where("id = ? AND user_id = ?", uint(recommendationID), userID).Delete(&models.AIRecommendation{})
if result.RowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Recommendation not found"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Recommendation deleted successfully"})
}
// GetInsights returns AI insights about user patterns
func (h *AIRecommendationHandler) GetInsights(c *gin.Context) {
userID := c.GetUint("user_id")
var insights struct {
TopInterests []string `json:"top_interests"`
LearningPaths []string `json:"learning_paths"`
ProductivityTips []string `json:"productivity_tips"`
ConnectionSuggestions []string `json:"connection_suggestions"`
Patterns struct {
BestProductivityHours []string `json:"best_productivity_hours"`
PreferredContentTypes []string `json:"preferred_content_types"`
LearningStyle string `json:"learning_style"`
} `json:"patterns"`
}
// Get user's top interests from bookmarks and tags
var interests []struct {
Tag string `json:"tag"`
Count int64 `json:"count"`
}
h.db.Raw(`
SELECT unnest(string_to_array(tags, ',')) as tag, COUNT(*) as count
FROM bookmarks
WHERE user_id = ? AND tags != ''
GROUP BY tag
ORDER BY count DESC
LIMIT 10
`, userID).Scan(&interests)
for _, interest := range interests {
insights.TopInterests = append(insights.TopInterests, interest.Tag)
}
// Get learning path suggestions
var learningPaths []struct {
Category string `json:"category"`
Count int64 `json:"count"`
}
h.db.Raw(`
SELECT lp.category, COUNT(*) as count
FROM learning_paths lp
JOIN enrollments e ON lp.id = e.learning_path_id
WHERE e.user_id = ? AND e.progress < 100
GROUP BY lp.category
ORDER BY count DESC
LIMIT 5
`, userID).Scan(&learningPaths)
for _, path := range learningPaths {
insights.LearningPaths = append(insights.LearningPaths, path.Category)
}
// Generate productivity tips based on task patterns
insights.ProductivityTips = []string{
"You complete most tasks in the morning - consider scheduling important work before noon",
"Tasks with deadlines are completed 80% faster - set more deadlines",
"You're most productive on Tuesdays and Wednesdays",
}
// Generate connection suggestions
topInterest := "technology"
if len(insights.TopInterests) > 0 {
topInterest = insights.TopInterests[0]
}
learningFocus := "productivity"
if len(insights.LearningPaths) > 0 {
learningFocus = insights.LearningPaths[0]
}
insights.ConnectionSuggestions = []string{
"Connect with users who share your interest in " + topInterest,
"Join communities focused on " + learningFocus,
}
// Analyze patterns
insights.Patterns.BestProductivityHours = []string{"9:00 AM - 11:00 AM", "2:00 PM - 4:00 PM"}
insights.Patterns.PreferredContentTypes = []string{"bookmarks", "notes", "courses"}
insights.Patterns.LearningStyle = "Visual learner who prefers structured content"
c.JSON(http.StatusOK, gin.H{"insights": insights})
}
-388
View File
@@ -1,388 +0,0 @@
package handlers
import (
"net/http"
"os"
"strconv"
"github.com/gin-gonic/gin"
"github.com/trackeep/backend/models"
)
// AISettings represents AI provider settings
type AISettings struct {
Mistral struct {
Enabled bool `json:"enabled"`
APIKey string `json:"api_key"`
Model string `json:"model"`
ModelThink string `json:"model_thinking"`
} `json:"mistral"`
Grok struct {
Enabled bool `json:"enabled"`
APIKey string `json:"api_key"`
BaseURL string `json:"base_url"`
Model string `json:"model"`
ModelThink string `json:"model_thinking"`
} `json:"grok"`
DeepSeek struct {
Enabled bool `json:"enabled"`
APIKey string `json:"api_key"`
BaseURL string `json:"base_url"`
Model string `json:"model"`
ModelThink string `json:"model_thinking"`
} `json:"deepseek"`
Ollama struct {
Enabled bool `json:"enabled"`
BaseURL string `json:"base_url"`
Model string `json:"model"`
ModelThink string `json:"model_thinking"`
} `json:"ollama"`
LongCat struct {
Enabled bool `json:"enabled"`
APIKey string `json:"api_key"`
BaseURL string `json:"base_url"`
OpenAIEndpoint string `json:"openai_endpoint"`
AnthropicEndpoint string `json:"anthropic_endpoint"`
Model string `json:"model"`
ModelThink string `json:"model_thinking"`
ModelThinkUpgraded string `json:"model_thinking_upgraded"`
Format string `json:"format"`
} `json:"longcat"`
OpenRouter struct {
Enabled bool `json:"enabled"`
APIKey string `json:"api_key"`
BaseURL string `json:"base_url"`
Model string `json:"model"`
ModelThink string `json:"model_thinking"`
} `json:"openrouter"`
}
// GetAISettings returns current AI settings (with API keys masked)
func GetAISettings(c *gin.Context) {
// Return settings based on environment variables
settings := getDefaultAISettings()
c.JSON(http.StatusOK, settings)
}
// UpdateAISettings updates user's AI settings
func UpdateAISettings(c *gin.Context) {
// Check if demo mode is enabled
if os.Getenv("VITE_DEMO_MODE") == "true" {
c.JSON(http.StatusOK, gin.H{"message": "AI settings updated successfully (demo mode)"})
return
}
userID := c.GetUint("user_id")
var req AISettings
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Get or create user settings
var userSettings models.UserAISettings
if err := models.DB.Where("user_id = ?", userID).First(&userSettings).Error; err != nil {
// Create new settings
userSettings.UserID = userID
}
// Update settings
userSettings.MistralEnabled = &req.Mistral.Enabled
if req.Mistral.APIKey != "" && !isMasked(req.Mistral.APIKey) {
userSettings.MistralAPIKey = req.Mistral.APIKey
}
userSettings.MistralModel = req.Mistral.Model
userSettings.MistralModelThinking = req.Mistral.ModelThink
userSettings.GrokEnabled = &req.Grok.Enabled
if req.Grok.APIKey != "" && !isMasked(req.Grok.APIKey) {
userSettings.GrokAPIKey = req.Grok.APIKey
}
userSettings.GrokBaseURL = req.Grok.BaseURL
userSettings.GrokModel = req.Grok.Model
userSettings.GrokModelThinking = req.Grok.ModelThink
userSettings.DeepSeekEnabled = &req.DeepSeek.Enabled
if req.DeepSeek.APIKey != "" && !isMasked(req.DeepSeek.APIKey) {
userSettings.DeepSeekAPIKey = req.DeepSeek.APIKey
}
userSettings.DeepSeekBaseURL = req.DeepSeek.BaseURL
userSettings.DeepSeekModel = req.DeepSeek.Model
userSettings.DeepSeekModelThinking = req.DeepSeek.ModelThink
userSettings.OllamaEnabled = &req.Ollama.Enabled
userSettings.OllamaBaseURL = req.Ollama.BaseURL
userSettings.OllamaModel = req.Ollama.Model
userSettings.OllamaModelThinking = req.Ollama.ModelThink
userSettings.LongCatEnabled = &req.LongCat.Enabled
if req.LongCat.APIKey != "" && !isMasked(req.LongCat.APIKey) {
userSettings.LongCatAPIKey = req.LongCat.APIKey
}
userSettings.LongCatBaseURL = req.LongCat.BaseURL
userSettings.LongCatOpenAIEndpoint = req.LongCat.OpenAIEndpoint
userSettings.LongCatAnthropicEndpoint = req.LongCat.AnthropicEndpoint
userSettings.LongCatModel = req.LongCat.Model
userSettings.LongCatModelThinking = req.LongCat.ModelThink
userSettings.LongCatModelThinkingUpgraded = req.LongCat.ModelThinkUpgraded
userSettings.LongCatFormat = req.LongCat.Format
userSettings.OpenRouterEnabled = &req.OpenRouter.Enabled
if req.OpenRouter.APIKey != "" && !isMasked(req.OpenRouter.APIKey) {
userSettings.OpenRouterAPIKey = req.OpenRouter.APIKey
}
userSettings.OpenRouterBaseURL = req.OpenRouter.BaseURL
userSettings.OpenRouterModel = req.OpenRouter.Model
userSettings.OpenRouterModelThinking = req.OpenRouter.ModelThink
// Save to database
if userSettings.ID == 0 {
if err := models.DB.Create(&userSettings).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save settings"})
return
}
} else {
if err := models.DB.Save(&userSettings).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update settings"})
return
}
}
c.JSON(http.StatusOK, gin.H{"message": "AI settings updated successfully"})
}
// TestAIConnection tests connection to AI provider
func TestAIConnection(c *gin.Context) {
// Check if demo mode is enabled
if os.Getenv("VITE_DEMO_MODE") == "true" {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Connection test successful (demo mode)",
})
return
}
userID := c.GetUint("user_id")
provider := c.Query("provider")
if provider == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Provider is required"})
return
}
// Get user's settings for this provider
var userSettings models.UserAISettings
if err := models.DB.Where("user_id = ?", userID).First(&userSettings).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "AI settings not found"})
return
}
// Test connection based on provider
var success bool
var message string
switch provider {
case "mistral":
if userSettings.MistralAPIKey == "" {
success = false
message = "Mistral API key not configured"
} else {
// TODO: Implement actual connection test
success = true
message = "Mistral connection test successful"
}
case "grok":
if userSettings.GrokAPIKey == "" {
success = false
message = "Grok API key not configured"
} else {
// TODO: Implement actual connection test
success = true
message = "Grok connection test successful"
}
case "deepseek":
if userSettings.DeepSeekAPIKey == "" {
success = false
message = "DeepSeek API key not configured"
} else {
// TODO: Implement actual connection test
success = true
message = "DeepSeek connection test successful"
}
case "longcat":
if userSettings.LongCatAPIKey == "" {
success = false
message = "LongCat API key not configured"
} else {
// TODO: Implement actual connection test
success = true
message = "LongCat connection test successful"
}
case "ollama":
if userSettings.OllamaBaseURL == "" {
success = false
message = "Ollama base URL not configured"
} else {
// TODO: Implement actual connection test
success = true
message = "Ollama connection test successful"
}
case "openrouter":
if userSettings.OpenRouterAPIKey == "" {
success = false
message = "OpenRouter API key not configured"
} else {
// TODO: Implement actual connection test
success = true
message = "OpenRouter connection test successful"
}
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "Unknown provider"})
return
}
c.JSON(http.StatusOK, gin.H{
"success": success,
"message": message,
})
}
// Helper functions
func getDefaultAISettings() AISettings {
settings := AISettings{}
// Simple approach - just set basic values without any complex logic
settings.Mistral.Enabled = false
settings.Mistral.APIKey = ""
settings.Mistral.Model = ""
settings.Mistral.ModelThink = ""
settings.Grok.Enabled = false
settings.Grok.APIKey = ""
settings.Grok.BaseURL = ""
settings.Grok.Model = ""
settings.Grok.ModelThink = ""
settings.DeepSeek.Enabled = false
settings.DeepSeek.APIKey = ""
settings.DeepSeek.BaseURL = ""
settings.DeepSeek.Model = ""
settings.DeepSeek.ModelThink = ""
settings.Ollama.Enabled = false
settings.Ollama.BaseURL = ""
settings.Ollama.Model = ""
settings.Ollama.ModelThink = ""
settings.LongCat.Enabled = false
settings.LongCat.APIKey = ""
settings.LongCat.BaseURL = ""
settings.LongCat.OpenAIEndpoint = ""
settings.LongCat.AnthropicEndpoint = ""
settings.LongCat.Model = ""
settings.LongCat.ModelThink = ""
settings.LongCat.ModelThinkUpgraded = ""
settings.LongCat.Format = ""
settings.OpenRouter.Enabled = false
settings.OpenRouter.APIKey = ""
settings.OpenRouter.BaseURL = ""
settings.OpenRouter.Model = ""
settings.OpenRouter.ModelThink = ""
// Read environment variables to determine enabled providers
// This works in both demo and production mode
if os.Getenv("MISTRAL_ON") == "true" {
settings.Mistral.Enabled = true
}
if os.Getenv("MISTRAL_API_KEY") != "" {
settings.Mistral.APIKey = "********"
}
settings.Mistral.Model = os.Getenv("MISTRAL_MODEL")
settings.Mistral.ModelThink = os.Getenv("MISTRAL_MODEL_THINKING")
if os.Getenv("LONGCAT_ON") == "true" {
settings.LongCat.Enabled = true
}
if os.Getenv("LONGCAT_API_KEY") != "" {
settings.LongCat.APIKey = "********"
}
settings.LongCat.BaseURL = os.Getenv("LONGCAT_BASE_URL")
settings.LongCat.OpenAIEndpoint = os.Getenv("LONGCAT_OPENAI_ENDPOINT")
settings.LongCat.AnthropicEndpoint = os.Getenv("LONGCAT_ANTHROPIC_ENDPOINT")
settings.LongCat.Model = os.Getenv("LONGCAT_MODEL")
settings.LongCat.ModelThink = os.Getenv("LONGCAT_MODEL_THINKING")
settings.LongCat.ModelThinkUpgraded = os.Getenv("LONGCAT_MODEL_THINKING_UPGRADED")
settings.LongCat.Format = os.Getenv("LONGCAT_FORMAT")
if os.Getenv("GROK_ON") == "true" {
settings.Grok.Enabled = true
}
if os.Getenv("GROK_API_KEY") != "" {
settings.Grok.APIKey = "********"
}
settings.Grok.BaseURL = os.Getenv("GROK_BASE_URL")
settings.Grok.Model = os.Getenv("GROK_MODEL")
settings.Grok.ModelThink = os.Getenv("GROK_MODEL_THINKING")
if os.Getenv("DEEPSEEK_ON") == "true" {
settings.DeepSeek.Enabled = true
}
if os.Getenv("DEEPSEEK_API_KEY") != "" {
settings.DeepSeek.APIKey = "********"
}
settings.DeepSeek.BaseURL = os.Getenv("DEEPSEEK_BASE_URL")
settings.DeepSeek.Model = os.Getenv("DEEPSEEK_MODEL")
settings.DeepSeek.ModelThink = os.Getenv("DEEPSEEK_MODEL_THINKING")
if os.Getenv("OLLAMA_ON") == "true" {
settings.Ollama.Enabled = true
}
settings.Ollama.BaseURL = os.Getenv("OLLAMA_BASE_URL")
settings.Ollama.Model = os.Getenv("OLLAMA_MODEL")
settings.Ollama.ModelThink = os.Getenv("OLLAMA_MODEL_THINKING")
if os.Getenv("OPENROUTER_ON") == "true" {
settings.OpenRouter.Enabled = true
}
if os.Getenv("OPENROUTER_API_KEY") != "" {
settings.OpenRouter.APIKey = "********"
}
settings.OpenRouter.BaseURL = os.Getenv("OPENROUTER_BASE_URL")
settings.OpenRouter.Model = os.Getenv("OPENROUTER_MODEL")
settings.OpenRouter.ModelThink = os.Getenv("OPENROUTER_MODEL_THINKING")
return settings
}
func maskAPIKey(key string) string {
if key == "" {
return ""
}
if len(key) <= 8 {
return "********"
}
return key[:4] + "********" + key[len(key)-4:]
}
func isMasked(key string) bool {
return key == "" || (len(key) > 8 && key[4:12] == "********")
}
func getBoolEnv(key string, defaultValue bool) bool {
value := os.Getenv(key)
if value == "" {
return defaultValue
}
boolValue, err := strconv.ParseBool(value)
if err != nil {
return defaultValue
}
return boolValue
}
+1 -1
View File
@@ -392,7 +392,7 @@ func Register(c *gin.Context) {
} }
// Provision messaging defaults (self chat, password vault, global channels). // Provision messaging defaults (self chat, password vault, global channels).
_ = ensureMessagingDefaults(db, user.ID)
// Generate JWT token // Generate JWT token
token, err := GenerateJWT(user) token, err := GenerateJWT(user)
-360
View File
@@ -1,360 +0,0 @@
package handlers
import (
"encoding/json"
"fmt"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/trackeep/backend/models"
"github.com/trackeep/backend/services"
)
// MistralConfig holds configuration for Mistral AI
type MistralConfig struct {
APIKey string
BaseURL string
Model string
MaxTokens int
Temperature float64
}
// ChatRequest represents a chat message request
type ChatRequest struct {
Message string `json:"message" binding:"required"`
SessionID *string `json:"session_id,omitempty"`
Context map[string]bool `json:"context,omitempty"` // what data to include
Provider string `json:"provider,omitempty"` // "mistral", "longcat", "grok", "deepseek", "ollama", "openrouter"
ModelType string `json:"model_type,omitempty"` // "standard", "thinking", "upgraded_thinking"
}
// ChatResponse represents a chat response
type ChatResponse struct {
ID string `json:"id"`
Message string `json:"message"`
Role string `json:"role"`
SessionID string `json:"session_id"`
Timestamp time.Time `json:"timestamp"`
Model string `json:"model"`
TokenUsage TokenUsage `json:"token_usage"`
ContextUsed []string `json:"context_used"`
}
// TokenUsage represents token usage information
type TokenUsage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
}
// MistralMessage represents a message for Mistral API
type MistralMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
// MistralRequest represents a request to Mistral API
type MistralRequest struct {
Model string `json:"model"`
Messages []MistralMessage `json:"messages"`
MaxTokens int `json:"max_tokens,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
}
// MistralResponse represents a response from Mistral API
type MistralResponse struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []struct {
Index int `json:"index"`
Message struct {
Role string `json:"role"`
Content string `json:"content"`
} `json:"message"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
} `json:"usage"`
}
var mistralConfig = MistralConfig{
APIKey: os.Getenv("MISTRAL_API_KEY"),
BaseURL: "https://api.mistral.ai/v1",
Model: "mistral-small-latest", // Cheap and capable model
MaxTokens: 4000,
Temperature: 0.7,
}
// GetMistralConfig returns current Mistral configuration
func GetMistralConfig() MistralConfig {
return mistralConfig
}
// SendMessage handles chat message requests
func SendMessage(c *gin.Context) {
userID := c.GetUint("user_id")
var req ChatRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Get or create session
var session models.ChatSession
if req.SessionID != nil {
if err := models.DB.Where("id = ? AND user_id = ?", *req.SessionID, userID).First(&session).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"})
return
}
} else {
// Create new session
session = models.ChatSession{
UserID: userID,
Title: fmt.Sprintf("Chat %s", time.Now().Format("Jan 2, 3:04 PM")),
IncludeBookmarks: true,
IncludeTasks: true,
IncludeFiles: true,
IncludeNotes: true,
}
if req.Context != nil {
session.IncludeBookmarks = req.Context["bookmarks"]
session.IncludeTasks = req.Context["tasks"]
session.IncludeFiles = req.Context["files"]
session.IncludeNotes = req.Context["notes"]
}
if err := models.DB.Create(&session).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create session"})
return
}
}
// Save user message
userMessage := models.ChatMessage{
UserID: userID,
SessionID: strconv.Itoa(int(session.ID)),
Content: req.Message,
Role: "user",
}
if err := models.DB.Create(&userMessage).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save message"})
return
}
// Get conversation history
var messages []models.ChatMessage
models.DB.Where("session_id = ?", session.ID).Order("created_at asc").Find(&messages)
// Build context from user data
contextData, err := buildUserContext(userID, session)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to build context"})
return
}
// Build messages for AI provider (system + history)
aiMessages := []services.Message{
{
Role: "system",
Content: buildSystemPrompt(contextData),
},
}
// Add conversation history (limit to last 10 messages to manage token count)
startIdx := 0
if len(messages) > 11 { // system + 10 messages
startIdx = len(messages) - 10
}
for i := startIdx; i < len(messages); i++ {
aiMessages = append(aiMessages, services.Message{
Role: messages[i].Role,
Content: messages[i].Content,
})
}
// Determine AI provider
aiProvider := services.ProviderMistral
switch req.Provider {
case "longcat":
aiProvider = services.ProviderLongCat
case "grok":
aiProvider = services.ProviderGrok
case "deepseek":
aiProvider = services.ProviderDeepSeek
case "ollama":
aiProvider = services.ProviderOllama
case "openrouter":
aiProvider = services.ProviderOpenRouter
}
aiService := services.NewAIService(aiProvider)
aiReq := services.AIRequest{
Messages: aiMessages,
MaxTokens: 2000,
Temperature: 0.7,
ModelType: req.ModelType,
}
// Call AI provider
startTime := time.Now()
var aiResp *services.AIResponse
switch req.ModelType {
case "thinking":
aiResp, err = aiService.ChatCompletionWithThinking(aiReq)
case "upgraded_thinking":
aiResp, err = aiService.ChatCompletionWithUpgradedThinking(aiReq)
default:
aiResp, err = aiService.ChatCompletion(aiReq)
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to call AI: " + err.Error()})
return
}
processingMs := time.Since(startTime).Milliseconds()
if len(aiResp.Choices) == 0 {
c.JSON(http.StatusInternalServerError, gin.H{"error": "No response from AI"})
return
}
// Extract assistant response, handling thinking models where needed
assistantContent := services.ParseThinkingResponse(aiResp, aiProvider, req.ModelType)
// Save assistant message
assistantMessage := models.ChatMessage{
UserID: userID,
SessionID: strconv.Itoa(int(session.ID)),
Content: assistantContent,
Role: "assistant",
ModelUsed: aiResp.Model,
TokenCount: aiResp.Usage.TotalTokens,
ProcessingMs: processingMs,
ContextItems: getContextItemIDs(contextData),
}
if err := models.DB.Create(&assistantMessage).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save response"})
return
}
// Update session
session.MessageCount = len(messages) + 1
now := time.Now()
session.LastMessageAt = &now
models.DB.Save(&session)
// Return response
response := ChatResponse{
ID: aiResp.ID,
Message: assistantContent,
Role: "assistant",
SessionID: strconv.Itoa(int(session.ID)),
Timestamp: time.Now(),
Model: aiResp.Model,
TokenUsage: TokenUsage{
PromptTokens: aiResp.Usage.PromptTokens,
CompletionTokens: aiResp.Usage.CompletionTokens,
TotalTokens: aiResp.Usage.TotalTokens,
},
ContextUsed: getContextItemIDs(contextData),
}
c.JSON(http.StatusOK, response)
}
// GetSessions retrieves user's chat sessions
func GetSessions(c *gin.Context) {
userID := c.GetUint("user_id")
var sessions []models.ChatSession
models.DB.Where("user_id = ?", userID).Order("updated_at desc").Find(&sessions)
c.JSON(http.StatusOK, sessions)
}
// GetSessionMessages retrieves messages for a specific session
func GetSessionMessages(c *gin.Context) {
userID := c.GetUint("user_id")
sessionID := c.Param("id")
// Verify session belongs to user
var session models.ChatSession
if err := models.DB.Where("id = ? AND user_id = ?", sessionID, userID).First(&session).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"})
return
}
var messages []models.ChatMessage
models.DB.Where("session_id = ?", sessionID).Order("created_at asc").Find(&messages)
c.JSON(http.StatusOK, messages)
}
// DeleteSession deletes a chat session and its messages
func DeleteSession(c *gin.Context) {
userID := c.GetUint("user_id")
sessionID := c.Param("id")
// Verify session belongs to user
var session models.ChatSession
if err := models.DB.Where("id = ? AND user_id = ?", sessionID, userID).First(&session).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"})
return
}
// Delete messages first
models.DB.Where("session_id = ?", sessionID).Delete(&models.ChatMessage{})
// Delete session
models.DB.Delete(&session)
c.JSON(http.StatusOK, gin.H{"message": "Session deleted"})
}
func callMistral(messages []MistralMessage) (*MistralResponse, error) {
reqBody := MistralRequest{
Model: mistralConfig.Model,
Messages: messages,
MaxTokens: mistralConfig.MaxTokens,
Temperature: mistralConfig.Temperature,
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
return nil, err
}
req, err := http.NewRequest("POST", mistralConfig.BaseURL+"/chat/completions", strings.NewReader(string(jsonData)))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+mistralConfig.APIKey)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("Mistral API returned status %d", resp.StatusCode)
}
var mistralResp MistralResponse
if err := json.NewDecoder(resp.Body).Decode(&mistralResp); err != nil {
return nil, err
}
return &mistralResp, nil
}
-181
View File
@@ -1,181 +0,0 @@
package handlers
import (
"fmt"
"strconv"
"github.com/trackeep/backend/models"
)
// UserContext represents the contextual data available to the AI
type UserContext struct {
Bookmarks []models.Bookmark
Tasks []models.Task
Files []models.File
Notes []models.Note
}
// buildUserContext gathers user data based on session configuration
func buildUserContext(userID uint, session models.ChatSession) (*UserContext, error) {
context := &UserContext{}
// Get bookmarks
if session.IncludeBookmarks {
var bookmarks []models.Bookmark
models.DB.Where("user_id = ?", userID).Limit(20).Order("updated_at desc").Find(&bookmarks)
context.Bookmarks = bookmarks
}
// Get tasks
if session.IncludeTasks {
var tasks []models.Task
models.DB.Where("user_id = ?", userID).Limit(20).Order("updated_at desc").Find(&tasks)
context.Tasks = tasks
}
// Get files
if session.IncludeFiles {
var files []models.File
models.DB.Where("user_id = ?", userID).Limit(20).Order("updated_at desc").Find(&files)
context.Files = files
}
// Get notes
if session.IncludeNotes {
var notes []models.Note
models.DB.Where("user_id = ?", userID).Limit(20).Order("updated_at desc").Find(&notes)
context.Notes = notes
}
return context, nil
}
// buildSystemPrompt creates a system prompt with user context
func buildSystemPrompt(context *UserContext) string {
prompt := `You are a helpful AI assistant for Trackeep, a personal productivity and knowledge management platform.
You have access to the user's personal data including bookmarks, tasks, files, and notes.
Your role is to help them organize, find information, and manage their digital life effectively.
Key capabilities:
- Help find specific bookmarks, tasks, or notes
- Suggest organization strategies
- Answer questions about their saved content
- Help with task planning and prioritization
- Assist with learning progress tracking
Be helpful, concise, and actionable. If you reference specific items, mention their titles or key details.
--- USER DATA ---`
// Add bookmarks context
if len(context.Bookmarks) > 0 {
prompt += "\n\nBOOKMARKS:\n"
for i, bookmark := range context.Bookmarks {
if i >= 10 { // Limit to prevent token overflow
prompt += "... and " + strconv.Itoa(len(context.Bookmarks)-10) + " more bookmarks\n"
break
}
prompt += fmt.Sprintf("- %s: %s", bookmark.Title, bookmark.URL)
if bookmark.Description != "" {
prompt += " (" + bookmark.Description + ")"
}
if bookmark.IsFavorite {
prompt += " ⭐"
}
prompt += "\n"
}
}
// Add tasks context
if len(context.Tasks) > 0 {
prompt += "\n\nTASKS:\n"
for i, task := range context.Tasks {
if i >= 10 {
prompt += "... and " + strconv.Itoa(len(context.Tasks)-10) + " more tasks\n"
break
}
status := string(task.Status)
priority := string(task.Priority)
prompt += fmt.Sprintf("- [%s] %s (Priority: %s)", status, task.Title, priority)
if task.DueDate != nil {
prompt += " Due: " + task.DueDate.Format("Jan 2")
}
prompt += "\n"
}
}
// Add files context
if len(context.Files) > 0 {
prompt += "\n\nFILES:\n"
for i, file := range context.Files {
if i >= 10 {
prompt += "... and " + strconv.Itoa(len(context.Files)-10) + " more files\n"
break
}
prompt += fmt.Sprintf("- %s (%s, %s)", file.OriginalName, file.FileType, formatFileSize(file.FileSize))
if file.Description != "" {
prompt += " - " + file.Description
}
prompt += "\n"
}
}
// Add notes context
if len(context.Notes) > 0 {
prompt += "\n\nNOTES:\n"
for i, note := range context.Notes {
if i >= 10 {
prompt += "... and " + strconv.Itoa(len(context.Notes)-10) + " more notes\n"
break
}
prompt += fmt.Sprintf("- %s", note.Title)
if note.Description != "" {
prompt += " - " + note.Description
}
if note.IsPinned {
prompt += " 📌"
}
prompt += "\n"
}
}
prompt += "\n--- END USER DATA ---\n\nNow respond to the user's message based on this context."
return prompt
}
// getContextItemIDs extracts IDs from context for tracking
func getContextItemIDs(context *UserContext) []string {
var ids []string
for _, bookmark := range context.Bookmarks {
ids = append(ids, "bookmark:"+strconv.Itoa(int(bookmark.ID)))
}
for _, task := range context.Tasks {
ids = append(ids, "task:"+strconv.Itoa(int(task.ID)))
}
for _, file := range context.Files {
ids = append(ids, "file:"+strconv.Itoa(int(file.ID)))
}
for _, note := range context.Notes {
ids = append(ids, "note:"+strconv.Itoa(int(note.ID)))
}
return ids
}
// formatFileSize formats file size in human readable format
func formatFileSize(bytes int64) string {
const unit = 1024
if bytes < unit {
return fmt.Sprintf("%d B", bytes)
}
div, exp := int64(unit), 0
for n := bytes / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -268,7 +268,7 @@ func upsertCentralizedOAuthUser(db *gorm.DB, controllerUser centralizedOAuthUser
return nil, err return nil, err
} }
_ = ensureMessagingDefaults(db, user.ID)
return &user, nil return &user, nil
} }
-662
View File
@@ -1,662 +0,0 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/trackeep/backend/models"
)
// SavedSearchRequest represents the request payload for creating/updating saved searches
type SavedSearchRequest struct {
Name string `json:"name" binding:"required"`
Query string `json:"query" binding:"required"`
Filters map[string]interface{} `json:"filters"`
Alert bool `json:"alert"`
IsPublic bool `json:"is_public"`
Description string `json:"description"`
Tags []string `json:"tags"`
}
// SavedSearchResponse represents the response payload for saved searches
type SavedSearchResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
Query string `json:"query"`
Filters map[string]interface{} `json:"filters"`
Alert bool `json:"alert"`
LastRun *time.Time `json:"last_run"`
RunCount int `json:"run_count"`
IsPublic bool `json:"is_public"`
Description string `json:"description"`
Tags []models.SavedSearchTag `json:"tags"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// CreateSavedSearch handles POST /api/v1/search/saved
func CreateSavedSearch(c *gin.Context) {
var req SavedSearchRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := c.GetUint("user_id")
if userID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
// Serialize filters to JSON
filtersJSON, err := json.Marshal(req.Filters)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid filters format"})
return
}
// Create saved search
savedSearch := models.SavedSearch{
UserID: userID,
Name: req.Name,
Query: req.Query,
Filters: string(filtersJSON),
Alert: req.Alert,
IsPublic: req.IsPublic,
RunCount: 0,
Tags: []models.SavedSearchTag{},
}
// Handle tags
if len(req.Tags) > 0 {
db := c.MustGet("db").(*gorm.DB)
for _, tagName := range req.Tags {
var tag models.SavedSearchTag
if err := db.Where("name = ?", tagName).First(&tag).Error; err != nil {
// Create new tag if it doesn't exist
tag = models.SavedSearchTag{
Name: tagName,
Color: "#3b82f6", // Default blue color
}
db.Create(&tag)
}
savedSearch.Tags = append(savedSearch.Tags, tag)
}
}
db := c.MustGet("db").(*gorm.DB)
if err := db.Create(&savedSearch).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create saved search"})
return
}
// Load tags for response
db.Preload("Tags").First(&savedSearch, savedSearch.ID)
response := SavedSearchResponse{
ID: savedSearch.ID,
Name: savedSearch.Name,
Query: savedSearch.Query,
Alert: savedSearch.Alert,
LastRun: savedSearch.LastRun,
RunCount: savedSearch.RunCount,
IsPublic: savedSearch.IsPublic,
Description: savedSearch.Description,
Tags: savedSearch.Tags,
CreatedAt: savedSearch.CreatedAt,
UpdatedAt: savedSearch.UpdatedAt,
}
// Parse filters back to map
json.Unmarshal([]byte(savedSearch.Filters), &response.Filters)
c.JSON(http.StatusCreated, response)
}
// GetUserSavedSearches handles GET /api/v1/search/saved
func GetUserSavedSearches(c *gin.Context) {
userID := c.GetUint("user_id")
if userID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
db := c.MustGet("db").(*gorm.DB)
// Parse query parameters
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
tagFilter := c.Query("tag")
alertFilter := c.Query("alert")
offset := (page - 1) * limit
query := db.Model(&models.SavedSearch{}).Where("user_id = ? OR is_public = ?", userID, true)
// Apply filters
if tagFilter != "" {
query = query.Joins("JOIN saved_search_tags ON saved_search_tags.id = saved_searches.id").
Joins("JOIN saved_search_tag_saved_searches ON saved_search_tag_saved_searches.saved_search_id = saved_searches.id").
Joins("JOIN saved_search_tags t ON t.id = saved_search_tag_saved_searches.saved_search_tag_id").
Where("t.name = ?", tagFilter)
}
if alertFilter == "true" {
query = query.Where("alert = ?", true)
} else if alertFilter == "false" {
query = query.Where("alert = ?", false)
}
var savedSearches []models.SavedSearch
var total int64
if err := query.Preload("Tags").Count(&total).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to count saved searches"})
return
}
if err := query.Preload("Tags").Offset(offset).Limit(limit).Order("created_at DESC").Find(&savedSearches).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch saved searches"})
return
}
// Convert to response format
var responses []SavedSearchResponse
for _, ss := range savedSearches {
var filters map[string]interface{}
json.Unmarshal([]byte(ss.Filters), &filters)
response := SavedSearchResponse{
ID: ss.ID,
Name: ss.Name,
Query: ss.Query,
Filters: filters,
Alert: ss.Alert,
LastRun: ss.LastRun,
RunCount: ss.RunCount,
IsPublic: ss.IsPublic,
Description: ss.Description,
Tags: ss.Tags,
CreatedAt: ss.CreatedAt,
UpdatedAt: ss.UpdatedAt,
}
responses = append(responses, response)
}
c.JSON(http.StatusOK, gin.H{
"saved_searches": responses,
"total": total,
"page": page,
"limit": limit,
})
}
// GetSavedSearch handles GET /api/v1/search/saved/:id
func GetSavedSearch(c *gin.Context) {
userID := c.GetUint("user_id")
if userID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid saved search ID"})
return
}
db := c.MustGet("db").(*gorm.DB)
var savedSearch models.SavedSearch
if err := db.Preload("Tags").Where("id = ? AND (user_id = ? OR is_public = ?)", id, userID, true).First(&savedSearch).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Saved search not found"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch saved search"})
}
return
}
var filters map[string]interface{}
json.Unmarshal([]byte(savedSearch.Filters), &filters)
response := SavedSearchResponse{
ID: savedSearch.ID,
Name: savedSearch.Name,
Query: savedSearch.Query,
Filters: filters,
Alert: savedSearch.Alert,
LastRun: savedSearch.LastRun,
RunCount: savedSearch.RunCount,
IsPublic: savedSearch.IsPublic,
Description: savedSearch.Description,
Tags: savedSearch.Tags,
CreatedAt: savedSearch.CreatedAt,
UpdatedAt: savedSearch.UpdatedAt,
}
c.JSON(http.StatusOK, response)
}
// UpdateSavedSearch handles PUT /api/v1/search/saved/:id
func UpdateSavedSearch(c *gin.Context) {
userID := c.GetUint("user_id")
if userID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid saved search ID"})
return
}
var req SavedSearchRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
db := c.MustGet("db").(*gorm.DB)
var savedSearch models.SavedSearch
if err := db.Where("id = ? AND user_id = ?", id, userID).First(&savedSearch).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Saved search not found"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch saved search"})
}
return
}
// Update fields
savedSearch.Name = req.Name
savedSearch.Query = req.Query
savedSearch.Alert = req.Alert
savedSearch.IsPublic = req.IsPublic
savedSearch.Description = req.Description
// Update filters
filtersJSON, err := json.Marshal(req.Filters)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid filters format"})
return
}
savedSearch.Filters = string(filtersJSON)
// Update tags
if err := db.Model(&savedSearch).Association("Tags").Clear(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clear tags"})
return
}
for _, tagName := range req.Tags {
var tag models.SavedSearchTag
if err := db.Where("name = ?", tagName).First(&tag).Error; err != nil {
tag = models.SavedSearchTag{
Name: tagName,
Color: "#3b82f6",
}
db.Create(&tag)
}
if err := db.Model(&savedSearch).Association("Tags").Append(&tag); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add tag"})
return
}
}
if err := db.Save(&savedSearch).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update saved search"})
return
}
// Load updated data
db.Preload("Tags").First(&savedSearch, savedSearch.ID)
var filters map[string]interface{}
json.Unmarshal([]byte(savedSearch.Filters), &filters)
response := SavedSearchResponse{
ID: savedSearch.ID,
Name: savedSearch.Name,
Query: savedSearch.Query,
Filters: filters,
Alert: savedSearch.Alert,
LastRun: savedSearch.LastRun,
RunCount: savedSearch.RunCount,
IsPublic: savedSearch.IsPublic,
Description: savedSearch.Description,
Tags: savedSearch.Tags,
CreatedAt: savedSearch.CreatedAt,
UpdatedAt: savedSearch.UpdatedAt,
}
c.JSON(http.StatusOK, response)
}
// DeleteSavedSearch handles DELETE /api/v1/search/saved/:id
func DeleteSavedSearch(c *gin.Context) {
userID := c.GetUint("user_id")
if userID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid saved search ID"})
return
}
db := c.MustGet("db").(*gorm.DB)
result := db.Where("id = ? AND user_id = ?", id, userID).Delete(&models.SavedSearch{})
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete saved search"})
return
}
if result.RowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Saved search not found"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Saved search deleted successfully"})
}
// RunSavedSearch handles POST /api/v1/search/saved/:id/run
func RunSavedSearch(c *gin.Context) {
userID := c.GetUint("user_id")
if userID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid saved search ID"})
return
}
db := c.MustGet("db").(*gorm.DB)
var savedSearch models.SavedSearch
if err := db.Where("id = ? AND (user_id = ? OR is_public = ?)", id, userID, true).First(&savedSearch).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Saved search not found"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch saved search"})
}
return
}
// Parse filters
var filters map[string]interface{}
if err := json.Unmarshal([]byte(savedSearch.Filters), &filters); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to parse filters"})
return
}
// Create search request based on saved search
searchReq := map[string]interface{}{
"query": savedSearch.Query,
}
// Merge filters
for k, v := range filters {
searchReq[k] = v
}
// Perform the search using existing enhanced search logic
// This is a simplified version - in production, you'd want to reuse the actual search handler
searchResults, err := performSearchFromSavedSearch(searchReq, userID, db)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to execute search"})
return
}
// Update saved search run statistics
now := time.Now()
savedSearch.LastRun = &now
savedSearch.RunCount++
db.Save(&savedSearch)
// Log search analytics
logSearchAnalytics(userID, savedSearch.Query, savedSearch.Filters, len(searchResults), db)
c.JSON(http.StatusOK, gin.H{
"results": searchResults,
"query": savedSearch.Query,
"filters": filters,
"total": len(searchResults),
"saved_search": gin.H{
"id": savedSearch.ID,
"name": savedSearch.Name,
"last_run": savedSearch.LastRun,
"run_count": savedSearch.RunCount,
},
})
}
// GetSavedSearchTags handles GET /api/v1/search/saved/tags
func GetSavedSearchTags(c *gin.Context) {
userID := c.GetUint("user_id")
if userID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
db := c.MustGet("db").(*gorm.DB)
var tags []models.SavedSearchTag
if err := db.Order("name").Find(&tags).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch tags"})
return
}
c.JSON(http.StatusOK, gin.H{"tags": tags})
}
// Helper function to perform search from saved search
func performSearchFromSavedSearch(searchReq map[string]interface{}, userID uint, db *gorm.DB) ([]interface{}, error) {
// Build search filters from the request
filters := SearchFilters{
Query: getStringValue(searchReq, "query"),
ContentType: getStringValue(searchReq, "content_type"),
Limit: getIntValue(searchReq, "limit", 20),
Offset: getIntValue(searchReq, "offset", 0),
}
// Parse tags if present
if tags, ok := searchReq["tags"].([]interface{}); ok {
for _, tag := range tags {
if tagStr, ok := tag.(string); ok {
filters.Tags = append(filters.Tags, tagStr)
}
}
}
// Parse date range if present
if dateRange, ok := searchReq["date_range"].(map[string]interface{}); ok {
if startStr, ok := dateRange["start"].(string); ok && startStr != "" {
if startTime, err := time.Parse("2006-01-02", startStr); err == nil {
filters.DateRange.Start = startTime
}
}
if endStr, ok := dateRange["end"].(string); ok && endStr != "" {
if endTime, err := time.Parse("2006-01-02", endStr); err == nil {
filters.DateRange.End = endTime
}
}
}
// Parse boolean filters
if isFavorite, ok := searchReq["is_favorite"].(bool); ok {
filters.IsFavorite = &isFavorite
}
if isRead, ok := searchReq["is_read"].(bool); ok {
filters.IsRead = &isRead
}
if isPublic, ok := searchReq["is_public"].(bool); ok {
filters.IsPublic = &isPublic
}
// Perform the search using existing enhanced search logic
results, err := performEnhancedSearch(filters, userID, db)
if err != nil {
return nil, err
}
// Convert results to interface slice
var interfaceResults []interface{}
for _, result := range results {
interfaceResults = append(interfaceResults, result)
}
return interfaceResults, nil
}
// Helper function to perform enhanced search (reused from search_enhanced.go)
func performEnhancedSearch(filters SearchFilters, userID uint, db *gorm.DB) ([]SearchResult, error) {
var results []SearchResult
// Search bookmarks
if filters.ContentType == "all" || filters.ContentType == "bookmarks" {
var bookmarks []models.Bookmark
query := db.Where("user_id = ?", userID)
// Apply text search
if filters.Query != "" {
query = query.Where("title ILIKE ? OR description ILIKE ? OR content ILIKE ?",
"%"+filters.Query+"%", "%"+filters.Query+"%", "%"+filters.Query+"%")
}
// Apply filters
if filters.IsFavorite != nil {
query = query.Where("is_favorite = ?", *filters.IsFavorite)
}
if err := query.Limit(filters.Limit).Offset(filters.Offset).Find(&bookmarks).Error; err != nil {
return nil, err
}
for _, bookmark := range bookmarks {
result := SearchResult{
ID: bookmark.ID,
Type: "bookmark",
Title: bookmark.Title,
Description: bookmark.Description,
Content: bookmark.Content,
CreatedAt: bookmark.CreatedAt,
UpdatedAt: bookmark.UpdatedAt,
URL: bookmark.URL,
IsFavorite: bookmark.IsFavorite,
IsRead: bookmark.IsRead,
}
results = append(results, result)
}
}
// Search tasks
if filters.ContentType == "all" || filters.ContentType == "tasks" {
var tasks []models.Task
query := db.Where("user_id = ?", userID)
if filters.Query != "" {
query = query.Where("title ILIKE ? OR description ILIKE ?",
"%"+filters.Query+"%", "%"+filters.Query+"%")
}
if err := query.Limit(filters.Limit).Offset(filters.Offset).Find(&tasks).Error; err != nil {
return nil, err
}
for _, task := range tasks {
result := SearchResult{
ID: task.ID,
Type: "task",
Title: task.Title,
Description: task.Description,
CreatedAt: task.CreatedAt,
UpdatedAt: task.UpdatedAt,
Status: string(task.Status),
Priority: string(task.Priority),
DueDate: task.DueDate,
}
results = append(results, result)
}
}
// Search notes
if filters.ContentType == "all" || filters.ContentType == "notes" {
var notes []models.Note
query := db.Where("user_id = ?", userID)
if filters.Query != "" {
query = query.Where("title ILIKE ? OR content ILIKE ?",
"%"+filters.Query+"%", "%"+filters.Query+"%")
}
if err := query.Limit(filters.Limit).Offset(filters.Offset).Find(&notes).Error; err != nil {
return nil, err
}
for _, note := range notes {
result := SearchResult{
ID: note.ID,
Type: "note",
Title: note.Title,
Description: note.Content[:min(200, len(note.Content))],
Content: note.Content,
CreatedAt: note.CreatedAt,
UpdatedAt: note.UpdatedAt,
IsPublic: note.IsPublic,
}
results = append(results, result)
}
}
return results, nil
}
// Helper functions
func getStringValue(m map[string]interface{}, key string) string {
if val, ok := m[key].(string); ok {
return val
}
return ""
}
func getIntValue(m map[string]interface{}, key string, defaultValue int) int {
if val, ok := m[key].(float64); ok {
return int(val)
}
return defaultValue
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
// Helper function to log search analytics
func logSearchAnalytics(userID uint, query string, filters string, resultsCount int, db *gorm.DB) {
analytics := models.SearchAnalytics{
UserID: userID,
Query: query,
Filters: filters,
ResultsCount: resultsCount,
Took: 0, // Would be measured in actual implementation
ContentType: "mixed",
}
db.Create(&analytics)
}
-475
View File
@@ -1,475 +0,0 @@
package handlers
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"github.com/gin-gonic/gin"
)
// BraveSearchResponse represents the response from Brave Search API
type BraveSearchResponse struct {
Mixed struct {
Results []map[string]interface{} `json:"results"`
} `json:"mixed"`
Web struct {
Results []map[string]interface{} `json:"results"`
} `json:"web"`
Query struct {
Original string `json:"original"`
Display string `json:"display"`
} `json:"query"`
}
type BraveNewsResponse struct {
Results []map[string]interface{} `json:"results"`
Query struct {
Original string `json:"original"`
Display string `json:"display"`
} `json:"query"`
}
// BraveSearchResult represents a normalized search result returned to the frontend
// Note: Brave's API uses fields like "page_age"; we normalize this to "published_date"
// to match the BrowserSearch UI expectations.
type BraveSearchResult struct {
Title string `json:"title"`
URL string `json:"url"`
Description string `json:"description"`
PublishedDate string `json:"published_date,omitempty"`
Language string `json:"language,omitempty"`
}
// SearchWeb handles POST /api/v1/search/web
func SearchWeb(c *gin.Context) {
var req struct {
Query string `json:"query" binding:"required"`
Count int `json:"count"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Set default count if not provided
if req.Count == 0 {
req.Count = 10
}
// Get user ID from context (authentication is required)
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required for search functionality"})
return
}
// Get user's search settings from database
searchSettings, err := GetSearchSettingsForAPI(userID.(int))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get search settings"})
return
}
// Check if user has search API key configured
if searchSettings.SearchAPIProvider == "brave" {
apiKey := searchSettings.BraveAPIKey
if apiKey == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Brave Search API key not configured. Please configure your search API key in settings."})
return
}
// Build Brave Search API request
baseURL := "https://api.search.brave.com/res/v1/web/search"
q := url.Values{}
q.Set("q", req.Query)
q.Set("count", fmt.Sprint(req.Count))
endpoint := fmt.Sprintf("%s?%s", baseURL, q.Encode())
reqHTTP, err := http.NewRequest(http.MethodGet, endpoint, nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create Brave request"})
return
}
reqHTTP.Header.Set("Accept", "application/json")
reqHTTP.Header.Set("X-Subscription-Token", apiKey)
resp, err := http.DefaultClient.Do(reqHTTP)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to contact Brave Search API"})
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("Brave API error: %d", resp.StatusCode)})
return
}
var braveResp BraveSearchResponse
if err := json.NewDecoder(resp.Body).Decode(&braveResp); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to decode Brave response"})
return
}
// Prefer web.results, fall back to mixed.results
resultsRaw := braveResp.Web.Results
if len(resultsRaw) == 0 {
resultsRaw = braveResp.Mixed.Results
}
results := make([]BraveSearchResult, 0, len(resultsRaw))
for _, r := range resultsRaw {
title, _ := r["title"].(string)
urlStr, _ := r["url"].(string)
desc, _ := r["description"].(string)
lang, _ := r["language"].(string)
pageAge, _ := r["page_age"].(string)
results = append(results, BraveSearchResult{
Title: title,
URL: urlStr,
Description: desc,
PublishedDate: pageAge,
Language: lang,
})
}
c.JSON(http.StatusOK, gin.H{
"results": results,
"query": gin.H{
"original": braveResp.Query.Original,
"display": braveResp.Query.Display,
},
"count": len(results),
})
return
}
// Use the configured provider
if searchSettings.SearchAPIProvider == "brave" {
apiKey := searchSettings.BraveAPIKey
if apiKey == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Brave Search API key not configured. Please configure your search API key in settings."})
return
}
// Build Brave Search API request
baseURL := searchSettings.BraveSearchBaseURL
q := url.Values{}
q.Set("q", req.Query)
q.Set("count", fmt.Sprint(req.Count))
endpoint := fmt.Sprintf("%s?%s", baseURL, q.Encode())
reqHTTP, err := http.NewRequest(http.MethodGet, endpoint, nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create Brave request"})
return
}
reqHTTP.Header.Set("Accept", "application/json")
reqHTTP.Header.Set("X-Subscription-Token", apiKey)
resp, err := http.DefaultClient.Do(reqHTTP)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to contact Brave Search API"})
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("Brave API error: %d", resp.StatusCode)})
return
}
var braveResp BraveSearchResponse
if err := json.NewDecoder(resp.Body).Decode(&braveResp); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to decode Brave response"})
return
}
// Prefer web.results, fall back to mixed.results
resultsRaw := braveResp.Web.Results
if len(resultsRaw) == 0 {
resultsRaw = braveResp.Mixed.Results
}
results := make([]BraveSearchResult, 0, len(resultsRaw))
for _, r := range resultsRaw {
title, _ := r["title"].(string)
urlStr, _ := r["url"].(string)
desc, _ := r["description"].(string)
lang, _ := r["language"].(string)
pageAge, _ := r["page_age"].(string)
results = append(results, BraveSearchResult{
Title: title,
URL: urlStr,
Description: desc,
PublishedDate: pageAge,
Language: lang,
})
}
c.JSON(http.StatusOK, gin.H{
"results": results,
"query": gin.H{
"original": braveResp.Query.Original,
"display": braveResp.Query.Display,
},
"count": len(results),
})
} else if searchSettings.SearchAPIProvider == "serper" {
// TODO: Implement Serper API integration
c.JSON(http.StatusNotImplemented, gin.H{"error": "Serper API integration not yet implemented"})
} else {
c.JSON(http.StatusBadRequest, gin.H{"error": "No valid search API provider configured. Please configure a search API provider in settings."})
}
}
// SearchNews handles POST /api/v1/search/news
func SearchNews(c *gin.Context) {
var req struct {
Query string `json:"query" binding:"required"`
Count int `json:"count"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Count == 0 {
req.Count = 10
}
// Get user ID from context (authentication is required)
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required for search functionality"})
return
}
// Get user's search settings from database
searchSettings, err := GetSearchSettingsForAPI(userID.(int))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get search settings"})
return
}
// Check if user has search API key configured
if searchSettings.SearchAPIProvider == "brave" {
apiKey := searchSettings.BraveAPIKey
if apiKey == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Brave Search API key not configured. Please configure your search API key in settings."})
return
}
baseURL := "https://api.search.brave.com/res/v1/news/search"
q := url.Values{}
q.Set("q", req.Query)
q.Set("count", fmt.Sprint(req.Count))
endpoint := fmt.Sprintf("%s?%s", baseURL, q.Encode())
reqHTTP, err := http.NewRequest(http.MethodGet, endpoint, nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create Brave request"})
return
}
reqHTTP.Header.Set("Accept", "application/json")
reqHTTP.Header.Set("X-Subscription-Token", apiKey)
resp, err := http.DefaultClient.Do(reqHTTP)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to contact Brave News API"})
return
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("Brave News API error: %d", resp.StatusCode)})
return
}
// Read the response body for debugging
bodyBytes, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read response body"})
return
}
var braveResp BraveNewsResponse
if err := json.NewDecoder(bytes.NewReader(bodyBytes)).Decode(&braveResp); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to decode Brave news response"})
return
}
// Debug logging
resultsRaw := braveResp.Results
results := make([]BraveSearchResult, 0, len(resultsRaw))
for _, r := range resultsRaw {
title, _ := r["title"].(string)
urlStr, _ := r["url"].(string)
desc, _ := r["description"].(string)
lang, _ := r["language"].(string)
pubDate, _ := r["published_date"].(string)
if pubDate == "" {
pubDate, _ = r["page_age"].(string)
}
results = append(results, BraveSearchResult{
Title: title,
URL: urlStr,
Description: desc,
PublishedDate: pubDate,
Language: lang,
})
}
original := braveResp.Query.Original
display := braveResp.Query.Display
if original == "" {
original = req.Query
}
if display == "" {
display = req.Query
}
c.JSON(http.StatusOK, gin.H{
"results": results,
"query": gin.H{
"original": original,
"display": display,
},
"count": len(results),
})
return
}
// Use the configured provider
if searchSettings.SearchAPIProvider == "brave" {
apiKey := searchSettings.BraveAPIKey
if apiKey == "" {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Brave API key not configured"})
return
}
baseURL := "https://api.search.brave.com/res/v1/news/search"
q := url.Values{}
q.Set("q", req.Query)
q.Set("count", fmt.Sprint(req.Count))
endpoint := fmt.Sprintf("%s?%s", baseURL, q.Encode())
reqHTTP, err := http.NewRequest(http.MethodGet, endpoint, nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create Brave request"})
return
}
reqHTTP.Header.Set("Accept", "application/json")
reqHTTP.Header.Set("X-Subscription-Token", apiKey)
resp, err := http.DefaultClient.Do(reqHTTP)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to contact Brave News API"})
return
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("Brave News API error: %d", resp.StatusCode)})
return
}
// Read the response body for debugging
bodyBytes, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read response body"})
return
}
var braveResp BraveNewsResponse
if err := json.NewDecoder(bytes.NewReader(bodyBytes)).Decode(&braveResp); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to decode Brave news response"})
return
}
// Debug logging
resultsRaw := braveResp.Results
results := make([]BraveSearchResult, 0, len(resultsRaw))
for _, r := range resultsRaw {
title, _ := r["title"].(string)
urlStr, _ := r["url"].(string)
desc, _ := r["description"].(string)
lang, _ := r["language"].(string)
pubDate, _ := r["published_date"].(string)
if pubDate == "" {
pubDate, _ = r["page_age"].(string)
}
results = append(results, BraveSearchResult{
Title: title,
URL: urlStr,
Description: desc,
PublishedDate: pubDate,
Language: lang,
})
}
original := braveResp.Query.Original
display := braveResp.Query.Display
if original == "" {
original = req.Query
}
if display == "" {
display = req.Query
}
c.JSON(http.StatusOK, gin.H{
"results": results,
"query": gin.H{
"original": original,
"display": display,
},
"count": len(results),
})
} else if searchSettings.SearchAPIProvider == "serper" {
// TODO: Implement Serper API integration for news
c.JSON(http.StatusNotImplemented, gin.H{"error": "Serper API integration not yet implemented"})
} else {
c.JSON(http.StatusBadRequest, gin.H{"error": "No valid search API provider configured. Please configure a search API provider in settings."})
}
}
// GetSearchSuggestions handles GET /api/v1/search/suggestions
func GetSearchSuggestions(c *gin.Context) {
query := c.Query("q")
if query == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Query parameter 'q' is required"})
return
}
// For now, return empty suggestions
// In a real implementation, you might want to implement autocomplete
// using Brave's autocomplete API or your own suggestion engine
c.JSON(http.StatusOK, gin.H{
"suggestions": []string{},
"query": query,
})
}
-523
View File
@@ -1,523 +0,0 @@
package handlers
import (
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/trackeep/backend/config"
"github.com/trackeep/backend/models"
"gorm.io/gorm"
)
// SearchFilters represents the search filters
type SearchFilters struct {
Query string `json:"query" binding:"required"`
ContentType string `json:"content_type"` // 'all' | 'bookmarks' | 'tasks' | 'notes' | 'files'
Tags []string `json:"tags"`
DateRange DateRange `json:"date_range"`
Author string `json:"author"`
Language string `json:"language"`
FileTypes []string `json:"file_types"`
IsFavorite *bool `json:"is_favorite"`
IsRead *bool `json:"is_read"`
IsPublic *bool `json:"is_public"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
type DateRange struct {
Start time.Time `json:"start"`
End time.Time `json:"end"`
}
// SearchResult represents a unified search result
type SearchResult struct {
ID uint `json:"id"`
Type string `json:"type"` // 'bookmark', 'task', 'note', 'file'
Title string `json:"title"`
Description string `json:"description"`
Content string `json:"content"`
Tags []models.Tag `json:"tags"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
URL string `json:"url,omitempty"`
Status string `json:"status,omitempty"`
Priority string `json:"priority,omitempty"`
DueDate *time.Time `json:"due_date,omitempty"`
IsFavorite bool `json:"is_favorite,omitempty"`
IsRead bool `json:"is_read,omitempty"`
IsPublic bool `json:"is_public,omitempty"`
Author string `json:"author,omitempty"`
FileSize int64 `json:"file_size,omitempty"`
MimeType string `json:"mime_type,omitempty"`
FileType string `json:"file_type,omitempty"`
Progress int `json:"progress,omitempty"`
Highights map[string][]string `json:"highlights,omitempty"` // Search highlights
Score float64 `json:"score"` // Relevance score
}
// SearchResponse represents the search response
type SearchResponse struct {
Results []SearchResult `json:"results"`
Total int64 `json:"total"`
Query string `json:"query"`
Filters SearchFilters `json:"filters"`
Took int64 `json:"took"` // Time taken in milliseconds
Suggestions []string `json:"suggestions"` // Search suggestions
Aggregations map[string]int `json:"aggregations"` // Content type counts
}
// EnhancedSearch handles POST /api/v1/search/enhanced
func EnhancedSearch(c *gin.Context) {
var filters SearchFilters
if err := c.ShouldBindJSON(&filters); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Set defaults
if filters.ContentType == "" {
filters.ContentType = "all"
}
if filters.Limit == 0 {
filters.Limit = 20
}
if filters.Limit > 100 {
filters.Limit = 100
}
startTime := time.Now()
db := config.GetDB()
userID := c.GetUint("user_id")
var results []SearchResult
var total int64
aggregations := make(map[string]int)
// Search based on content type
switch filters.ContentType {
case "bookmarks":
results, total = searchBookmarks(db, userID, filters)
aggregations["bookmarks"] = int(total)
case "tasks":
results, total = searchTasks(db, userID, filters)
aggregations["tasks"] = int(total)
case "notes":
results, total = searchNotes(db, userID, filters)
aggregations["notes"] = int(total)
case "files":
results, total = searchFiles(db, userID, filters)
aggregations["files"] = int(total)
default: // all
bookmarkResults, bookmarkTotal := searchBookmarks(db, userID, filters)
taskResults, taskTotal := searchTasks(db, userID, filters)
noteResults, noteTotal := searchNotes(db, userID, filters)
fileResults, fileTotal := searchFiles(db, userID, filters)
results = append(append(append(bookmarkResults, taskResults...), noteResults...), fileResults...)
total = bookmarkTotal + taskTotal + noteTotal + fileTotal
aggregations["bookmarks"] = int(bookmarkTotal)
aggregations["tasks"] = int(taskTotal)
aggregations["notes"] = int(noteTotal)
aggregations["files"] = int(fileTotal)
}
// Apply pagination
if filters.Offset > 0 && len(results) > filters.Offset {
results = results[filters.Offset:]
}
if len(results) > filters.Limit {
results = results[:filters.Limit]
}
// Get search suggestions
suggestions := getSearchSuggestions(db, userID, filters.Query)
// Calculate time taken
took := time.Since(startTime).Milliseconds()
response := SearchResponse{
Results: results,
Total: total,
Query: filters.Query,
Filters: filters,
Took: took,
Suggestions: suggestions,
Aggregations: aggregations,
}
c.JSON(http.StatusOK, response)
}
// searchBookmarks searches bookmarks with filters
func searchBookmarks(db *gorm.DB, userID uint, filters SearchFilters) ([]SearchResult, int64) {
var bookmarks []models.Bookmark
var results []SearchResult
query := db.Where("user_id = ?", userID)
// Text search
if filters.Query != "" {
searchTerm := "%" + strings.ToLower(filters.Query) + "%"
query = query.Where("LOWER(title) LIKE ? OR LOWER(description) LIKE ? OR LOWER(content) LIKE ? OR LOWER(url) LIKE ?",
searchTerm, searchTerm, searchTerm, searchTerm)
}
// Tags filter
if len(filters.Tags) > 0 {
query = query.Joins("JOIN bookmark_tags ON bookmarks.id = bookmark_tags.bookmark_id").
Joins("JOIN tags ON bookmark_tags.tag_id = tags.id").
Where("tags.name IN ?", filters.Tags)
}
// Date range filter
if !filters.DateRange.Start.IsZero() {
query = query.Where("created_at >= ?", filters.DateRange.Start)
}
if !filters.DateRange.End.IsZero() {
query = query.Where("created_at <= ?", filters.DateRange.End)
}
// Boolean filters
if filters.IsFavorite != nil {
query = query.Where("is_favorite = ?", *filters.IsFavorite)
}
if filters.IsRead != nil {
query = query.Where("is_read = ?", *filters.IsRead)
}
// Author filter
if filters.Author != "" {
query = query.Where("LOWER(author) LIKE ?", "%"+strings.ToLower(filters.Author)+"%")
}
// Count total
var total int64
query.Model(&models.Bookmark{}).Count(&total)
// Get results with tags
if err := query.Preload("Tags").Find(&bookmarks).Error; err != nil {
return results, 0
}
// Convert to search results
for _, bookmark := range bookmarks {
result := SearchResult{
ID: bookmark.ID,
Type: "bookmark",
Title: bookmark.Title,
Description: bookmark.Description,
Content: bookmark.Content,
Tags: bookmark.Tags,
CreatedAt: bookmark.CreatedAt,
UpdatedAt: bookmark.UpdatedAt,
URL: bookmark.URL,
IsFavorite: bookmark.IsFavorite,
IsRead: bookmark.IsRead,
Author: bookmark.Author,
Score: calculateRelevanceScore(filters.Query, bookmark.Title, bookmark.Description, bookmark.Content),
}
if bookmark.PublishedAt != nil {
result.DueDate = bookmark.PublishedAt // Using DueDate field for published date
}
results = append(results, result)
}
return results, total
}
// searchTasks searches tasks with filters
func searchTasks(db *gorm.DB, userID uint, filters SearchFilters) ([]SearchResult, int64) {
var tasks []models.Task
var results []SearchResult
query := db.Where("user_id = ?", userID)
// Text search
if filters.Query != "" {
searchTerm := "%" + strings.ToLower(filters.Query) + "%"
query = query.Where("LOWER(title) LIKE ? OR LOWER(description) LIKE ?", searchTerm, searchTerm)
}
// Tags filter
if len(filters.Tags) > 0 {
query = query.Joins("JOIN task_tags ON tasks.id = task_tags.task_id").
Joins("JOIN tags ON task_tags.tag_id = tags.id").
Where("tags.name IN ?", filters.Tags)
}
// Date range filter
if !filters.DateRange.Start.IsZero() {
query = query.Where("created_at >= ?", filters.DateRange.Start)
}
if !filters.DateRange.End.IsZero() {
query = query.Where("created_at <= ?", filters.DateRange.End)
}
// Count total
var total int64
query.Model(&models.Task{}).Count(&total)
// Get results with tags
if err := query.Preload("Tags").Find(&tasks).Error; err != nil {
return results, 0
}
// Convert to search results
for _, task := range tasks {
result := SearchResult{
ID: task.ID,
Type: "task",
Title: task.Title,
Description: task.Description,
Tags: task.Tags,
CreatedAt: task.CreatedAt,
UpdatedAt: task.UpdatedAt,
Status: string(task.Status),
Priority: string(task.Priority),
DueDate: task.DueDate,
Progress: task.Progress,
Score: calculateRelevanceScore(filters.Query, task.Title, task.Description, ""),
}
results = append(results, result)
}
return results, total
}
// searchNotes searches notes with filters
func searchNotes(db *gorm.DB, userID uint, filters SearchFilters) ([]SearchResult, int64) {
var notes []models.Note
var results []SearchResult
query := db.Where("user_id = ?", userID)
// Text search
if filters.Query != "" {
searchTerm := "%" + strings.ToLower(filters.Query) + "%"
query = query.Where("LOWER(title) LIKE ? OR LOWER(description) LIKE ? OR LOWER(content) LIKE ?",
searchTerm, searchTerm, searchTerm)
}
// Tags filter
if len(filters.Tags) > 0 {
query = query.Joins("JOIN note_tags ON notes.id = note_tags.note_id").
Joins("JOIN tags ON note_tags.tag_id = tags.id").
Where("tags.name IN ?", filters.Tags)
}
// Date range filter
if !filters.DateRange.Start.IsZero() {
query = query.Where("created_at >= ?", filters.DateRange.Start)
}
if !filters.DateRange.End.IsZero() {
query = query.Where("created_at <= ?", filters.DateRange.End)
}
// Boolean filters
if filters.IsPublic != nil {
query = query.Where("is_public = ?", *filters.IsPublic)
}
// Count total
var total int64
query.Model(&models.Note{}).Count(&total)
// Get results with tags
if err := query.Preload("Tags").Find(&notes).Error; err != nil {
return results, 0
}
// Convert to search results
for _, note := range notes {
result := SearchResult{
ID: note.ID,
Type: "note",
Title: note.Title,
Description: note.Description,
Content: note.Content,
Tags: note.Tags,
CreatedAt: note.CreatedAt,
UpdatedAt: note.UpdatedAt,
IsPublic: note.IsPublic,
Score: calculateRelevanceScore(filters.Query, note.Title, note.Description, note.Content),
}
results = append(results, result)
}
return results, total
}
// searchFiles searches files with filters
func searchFiles(db *gorm.DB, userID uint, filters SearchFilters) ([]SearchResult, int64) {
var files []models.File
var results []SearchResult
query := db.Where("user_id = ?", userID)
// Text search
if filters.Query != "" {
searchTerm := "%" + strings.ToLower(filters.Query) + "%"
query = query.Where("LOWER(original_name) LIKE ? OR LOWER(description) LIKE ? OR LOWER(content) LIKE ?",
searchTerm, searchTerm, searchTerm)
}
// Tags filter
if len(filters.Tags) > 0 {
query = query.Joins("JOIN file_tags ON files.id = file_tags.file_id").
Joins("JOIN tags ON file_tags.tag_id = tags.id").
Where("tags.name IN ?", filters.Tags)
}
// Date range filter
if !filters.DateRange.Start.IsZero() {
query = query.Where("created_at >= ?", filters.DateRange.Start)
}
if !filters.DateRange.End.IsZero() {
query = query.Where("created_at <= ?", filters.DateRange.End)
}
// File type filter
if len(filters.FileTypes) > 0 {
query = query.Where("file_type IN ?", filters.FileTypes)
}
// Boolean filters
if filters.IsPublic != nil {
query = query.Where("is_public = ?", *filters.IsPublic)
}
// Count total
var total int64
query.Model(&models.File{}).Count(&total)
// Get results with tags
if err := query.Preload("Tags").Find(&files).Error; err != nil {
return results, 0
}
// Convert to search results
for _, file := range files {
result := SearchResult{
ID: file.ID,
Type: "file",
Title: file.OriginalName,
Description: file.Description,
Content: file.Content,
Tags: file.Tags,
CreatedAt: file.CreatedAt,
UpdatedAt: file.UpdatedAt,
FileSize: file.FileSize,
MimeType: file.MimeType,
FileType: string(file.FileType),
IsPublic: file.IsPublic,
Score: calculateRelevanceScore(filters.Query, file.OriginalName, file.Description, file.Content),
}
results = append(results, result)
}
return results, total
}
// calculateRelevanceScore calculates a simple relevance score for search results
func calculateRelevanceScore(query, title, description, content string) float64 {
if query == "" {
return 1.0
}
queryLower := strings.ToLower(query)
titleLower := strings.ToLower(title)
descLower := strings.ToLower(description)
contentLower := strings.ToLower(content)
score := 0.0
// Title matches are most important
if strings.Contains(titleLower, queryLower) {
score += 10.0
if strings.HasPrefix(titleLower, queryLower) {
score += 5.0 // Bonus for prefix match
}
}
// Description matches
if strings.Contains(descLower, queryLower) {
score += 5.0
}
// Content matches
if strings.Contains(contentLower, queryLower) {
score += 2.0
}
// Word-based scoring
queryWords := strings.Fields(queryLower)
for _, word := range queryWords {
if strings.Contains(titleLower, word) {
score += 3.0
}
if strings.Contains(descLower, word) {
score += 1.5
}
if strings.Contains(contentLower, word) {
score += 1.0
}
}
return score
}
// getSearchSuggestions gets search suggestions based on user's search history and popular content
func getSearchSuggestions(db *gorm.DB, userID uint, query string) []string {
// For now, return empty suggestions
// In a future implementation, this could:
// - Look at user's search history
// - Suggest popular tags
// - Suggest based on content titles
// - Use AI to generate semantic suggestions
return []string{}
}
// SaveSearch handles POST /api/v1/search/save
func SaveSearch(c *gin.Context) {
var req struct {
Query string `json:"query" binding:"required"`
Filters SearchFilters `json:"filters"`
Name string `json:"name" binding:"required"`
Alert bool `json:"alert"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// TODO: Implement saved searches functionality
// This would require a SavedSearch model
c.JSON(http.StatusNotImplemented, gin.H{
"message": "Saved searches functionality coming soon",
})
}
// GetSearchAnalytics handles GET /api/v1/search/analytics
func GetSearchAnalytics(c *gin.Context) {
// TODO: Implement search analytics
// This could include:
// - Most searched terms
// - Search frequency over time
// - Content type distribution
// - Popular filters
c.JSON(http.StatusNotImplemented, gin.H{
"message": "Search analytics functionality coming soon",
})
}
-184
View File
@@ -1,184 +0,0 @@
package handlers
import (
"net/http"
"os"
"strconv"
"github.com/gin-gonic/gin"
"github.com/trackeep/backend/models"
)
// SearchSettings represents search API configuration
type SearchSettings struct {
BraveAPIKey string `json:"brave_api_key"`
BraveSearchBaseURL string `json:"brave_search_base_url"`
SerperAPIKey string `json:"serper_api_key"`
SerperBaseURL string `json:"serper_base_url"`
SearchAPIProvider string `json:"search_api_provider"`
SearchResultsLimit int `json:"search_results_limit"`
SearchCacheTTL int `json:"search_cache_ttl"`
SearchRateLimit int `json:"search_rate_limit"`
}
// GetSearchSettings handles GET /api/v1/auth/search/settings
func GetSearchSettings(c *gin.Context) {
userID := c.GetInt("user_id")
// Get settings from database
settings, err := models.GetUserSearchSettings(uint(userID))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get settings"})
return
}
// Convert to response format
response := SearchSettings{
BraveSearchBaseURL: settings.BraveSearchBaseURL,
SerperBaseURL: settings.SerperBaseURL,
SearchAPIProvider: settings.SearchAPIProvider,
SearchResultsLimit: settings.SearchResultsLimit,
SearchCacheTTL: settings.SearchCacheTTL,
SearchRateLimit: settings.SearchRateLimit,
}
// Mask API keys for security
if settings.BraveAPIKey != "" && len(settings.BraveAPIKey) > 8 {
response.BraveAPIKey = settings.BraveAPIKey[:4] + "********" + settings.BraveAPIKey[len(settings.BraveAPIKey)-4:]
}
if settings.SerperAPIKey != "" && len(settings.SerperAPIKey) > 8 {
response.SerperAPIKey = settings.SerperAPIKey[:4] + "********" + settings.SerperAPIKey[len(settings.SerperAPIKey)-4:]
}
c.JSON(http.StatusOK, response)
}
// UpdateSearchSettings handles PUT /api/v1/auth/search/settings
func UpdateSearchSettings(c *gin.Context) {
userID := c.GetInt("user_id")
var newSettings SearchSettings
if err := c.ShouldBindJSON(&newSettings); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Get existing settings to preserve API keys if they're masked
existingSettings, err := models.GetUserSearchSettings(uint(userID))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get existing settings"})
return
}
// Check if API keys are masked and preserve existing values
if len(newSettings.BraveAPIKey) > 8 && newSettings.BraveAPIKey[4:12] == "********" {
newSettings.BraveAPIKey = existingSettings.BraveAPIKey
}
if len(newSettings.SerperAPIKey) > 8 && newSettings.SerperAPIKey[4:12] == "********" {
newSettings.SerperAPIKey = existingSettings.SerperAPIKey
}
// Update model
updatedSettings := &models.UserSearchSettings{
BraveAPIKey: newSettings.BraveAPIKey,
BraveSearchBaseURL: newSettings.BraveSearchBaseURL,
SerperAPIKey: newSettings.SerperAPIKey,
SerperBaseURL: newSettings.SerperBaseURL,
SearchAPIProvider: newSettings.SearchAPIProvider,
SearchResultsLimit: newSettings.SearchResultsLimit,
SearchCacheTTL: newSettings.SearchCacheTTL,
SearchRateLimit: newSettings.SearchRateLimit,
}
// Save to database
err = models.SaveUserSearchSettings(uint(userID), updatedSettings)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save settings"})
return
}
// Return masked settings for consistency
GetSearchSettings(c)
}
// GetTestSearchSettings handles GET /api/v1/test-search-settings (for demo mode)
func GetTestSearchSettings(c *gin.Context) {
settings := getDefaultSearchSettings()
// Mask API keys for security
if settings.BraveAPIKey != "" && len(settings.BraveAPIKey) > 8 {
settings.BraveAPIKey = settings.BraveAPIKey[:4] + "********" + settings.BraveAPIKey[len(settings.BraveAPIKey)-4:]
}
if settings.SerperAPIKey != "" && len(settings.SerperAPIKey) > 8 {
settings.SerperAPIKey = settings.SerperAPIKey[:4] + "********" + settings.SerperAPIKey[len(settings.SerperAPIKey)-4:]
}
c.JSON(http.StatusOK, settings)
}
// GetSearchSettingsForAPI returns unmasked search settings for internal API use
func GetSearchSettingsForAPI(userID int) (SearchSettings, error) {
settings, err := models.GetUserSearchSettings(uint(userID))
if err != nil {
// Return default settings if error
defaultSettings := getDefaultSearchSettings()
return defaultSettings, nil
}
return SearchSettings{
BraveAPIKey: settings.BraveAPIKey,
BraveSearchBaseURL: settings.BraveSearchBaseURL,
SerperAPIKey: settings.SerperAPIKey,
SerperBaseURL: settings.SerperBaseURL,
SearchAPIProvider: settings.SearchAPIProvider,
SearchResultsLimit: settings.SearchResultsLimit,
SearchCacheTTL: settings.SearchCacheTTL,
SearchRateLimit: settings.SearchRateLimit,
}, nil
}
func getDefaultSearchSettings() SearchSettings {
return SearchSettings{
BraveAPIKey: getEnvWithDefault("BRAVE_API_KEY", "BSAw0HNI1v3rKmXlSTr0C_UfZDjw7fT"),
BraveSearchBaseURL: getEnvWithDefault("BRAVE_SEARCH_BASE_URL", "https://api.search.brave.com/res/v1/web/search"),
SerperAPIKey: getEnvWithDefault("SERPER_API_KEY", "6f1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2"),
SerperBaseURL: getEnvWithDefault("SERPER_BASE_URL", "https://google.serper.dev/search"),
SearchAPIProvider: getEnvWithDefault("SEARCH_API_PROVIDER", "brave"),
SearchResultsLimit: getIntEnvWithDefault("SEARCH_RESULTS_LIMIT", 10),
SearchCacheTTL: getIntEnvWithDefault("SEARCH_CACHE_TTL", 300),
SearchRateLimit: getIntEnvWithDefault("SEARCH_RATE_LIMIT", 100),
}
}
func getEnvWithDefault(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
func getIntEnvWithDefault(key string, defaultValue int) int {
value := os.Getenv(key)
if value == "" {
return defaultValue
}
if intValue, err := strconv.Atoi(value); err == nil {
return intValue
}
return defaultValue
}
func getBoolEnvWithDefault(key string, defaultValue bool) bool {
value := os.Getenv(key)
if value == "" {
return defaultValue
}
if value == "true" || value == "1" {
return true
}
return false
}
-603
View File
@@ -1,603 +0,0 @@
package handlers
import (
"encoding/json"
"fmt"
"log"
"math"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/trackeep/backend/config"
"github.com/trackeep/backend/models"
"gorm.io/gorm"
)
// SemanticSearchRequest represents a semantic search request
type SemanticSearchRequest struct {
Query string `json:"query" binding:"required"`
ContentType string `json:"content_type"` // all | bookmarks | tasks | notes | files | calendar_events | youtube_videos | learning_paths | chat_messages
Limit int `json:"limit"`
Threshold float64 `json:"threshold"` // Similarity threshold (0-1)
}
// SemanticSearchResponse represents semantic search response
type SemanticSearchResponse struct {
Results []SemanticSearchResult `json:"results"`
Query string `json:"query"`
Took int64 `json:"took"`
Model string `json:"model"`
}
// SemanticSearchResult represents a semantic search result
type SemanticSearchResult struct {
ID uint `json:"id"`
Type string `json:"type"`
Title string `json:"title"`
Description string `json:"description"`
Content string `json:"content"`
Similarity float64 `json:"similarity"`
Highlights []string `json:"highlights"`
Tags []models.Tag `json:"tags,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
URL string `json:"url,omitempty"`
Status string `json:"status,omitempty"`
Priority string `json:"priority,omitempty"`
}
// GenerateEmbeddingRequest represents request to generate embeddings
type GenerateEmbeddingRequest struct {
Text string `json:"text" binding:"required"`
ContentType string `json:"content_type"`
ContentID uint `json:"content_id"`
}
// GenerateEmbeddingResponse represents embedding generation response
type GenerateEmbeddingResponse struct {
Embedding []float64 `json:"embedding"`
Model string `json:"model"`
Dimensions int `json:"dimensions"`
Success bool `json:"success"`
Message string `json:"message"`
}
// SemanticSearch handles POST /api/v1/search/semantic
func SemanticSearch(c *gin.Context) {
var req SemanticSearchRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Set defaults
if req.Limit == 0 {
req.Limit = 20
}
if req.Threshold == 0 {
req.Threshold = 0.7 // Default similarity threshold
}
startTime := time.Now()
db := config.GetDB()
userID := c.GetUint("user_id")
// Generate embedding for the search query
queryEmbedding, err := generateEmbedding(req.Query)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to generate query embedding",
"details": err.Error(),
})
return
}
// Search for similar content
results, err := findSimilarContent(db, userID, queryEmbedding, req.ContentType, req.Limit, req.Threshold)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to search similar content",
"details": err.Error(),
})
return
}
took := time.Since(startTime).Milliseconds()
response := SemanticSearchResponse{
Results: results,
Query: req.Query,
Took: took,
Model: "text-embedding-ada-002",
}
c.JSON(http.StatusOK, response)
}
// GenerateEmbedding handles POST /api/v1/search/embeddings/generate
func GenerateEmbedding(c *gin.Context) {
var req GenerateEmbeddingRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Generate embedding
embedding, err := generateEmbedding(req.Text)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to generate embedding",
"details": err.Error(),
})
return
}
// Store embedding if content reference is provided
if req.ContentType != "" && req.ContentID > 0 {
db := config.GetDB()
userID := c.GetUint("user_id")
embeddingJSON, _ := json.Marshal(embedding)
contentEmbedding := models.ContentEmbedding{
ContentType: req.ContentType,
ContentID: req.ContentID,
Embedding: string(embeddingJSON),
Model: "text-embedding-ada-002",
Dimensions: len(embedding),
TextContent: req.Text,
UserID: userID,
}
if err := db.Create(&contentEmbedding).Error; err != nil {
// Log error but don't fail the request
log.Printf("Failed to store embedding: %v", err)
}
}
response := GenerateEmbeddingResponse{
Embedding: embedding,
Model: "text-embedding-ada-002",
Dimensions: len(embedding),
Success: true,
Message: "Embedding generated successfully",
}
c.JSON(http.StatusOK, response)
}
// ReindexContent handles POST /api/v1/search/reindex
func ReindexContent(c *gin.Context) {
db := config.GetDB()
userID := c.GetUint("user_id")
// Start background job to reindex all content
go func() {
reindexUserContent(db, userID)
}()
c.JSON(http.StatusOK, gin.H{
"message": "Content reindexing started in background",
"status": "processing",
})
}
// generateEmbedding generates embedding for text using OpenAI API (mock implementation)
func generateEmbedding(text string) ([]float64, error) {
// TODO: Replace with actual OpenAI API call
// For now, return a mock embedding for demonstration
embedding := make([]float64, 1536) // OpenAI embedding dimensions
// Generate pseudo-random but deterministic embedding based on text
hash := simpleHash(text)
for i := range embedding {
embedding[i] = math.Sin(float64(hash+i)) * 0.5
}
return embedding, nil
}
// simpleHash creates a simple hash from string
func simpleHash(s string) int {
hash := 0
for _, char := range s {
hash = hash*31 + int(char)
}
return hash
}
// findSimilarContent finds content similar to the given embedding
func findSimilarContent(db *gorm.DB, userID uint, queryEmbedding []float64, contentType string, limit int, threshold float64) ([]SemanticSearchResult, error) {
var results []SemanticSearchResult
// Get all embeddings for the user
var embeddings []models.ContentEmbedding
query := db.Where("user_id = ?", userID)
if contentType != "all" && contentType != "" {
query = query.Where("content_type = ?", normalizeSemanticContentType(contentType))
}
if err := query.Find(&embeddings).Error; err != nil {
return results, err
}
// Calculate similarity scores
type similarityScore struct {
embedding models.ContentEmbedding
score float64
}
var scores []similarityScore
for _, embedding := range embeddings {
var storedEmbedding []float64
if err := json.Unmarshal([]byte(embedding.Embedding), &storedEmbedding); err != nil {
continue
}
similarity := cosineSimilarity(queryEmbedding, storedEmbedding)
if similarity >= threshold {
scores = append(scores, similarityScore{
embedding: embedding,
score: similarity,
})
}
}
// Sort by similarity (descending)
for i := 0; i < len(scores)-1; i++ {
for j := i + 1; j < len(scores); j++ {
if scores[i].score < scores[j].score {
scores[i], scores[j] = scores[j], scores[i]
}
}
}
// Limit results
if len(scores) > limit {
scores = scores[:limit]
}
// Fetch actual content and build results
for _, score := range scores {
result, err := buildSemanticSearchResult(db, score.embedding, score.score)
if err != nil {
continue
}
results = append(results, result)
}
return results, nil
}
// cosineSimilarity calculates cosine similarity between two vectors
func cosineSimilarity(a, b []float64) float64 {
if len(a) != len(b) {
return 0
}
var dotProduct, normA, normB float64
for i := range a {
dotProduct += a[i] * b[i]
normA += a[i] * a[i]
normB += b[i] * b[i]
}
if normA == 0 || normB == 0 {
return 0
}
return dotProduct / (math.Sqrt(normA) * math.Sqrt(normB))
}
// buildSemanticSearchResult builds a search result from embedding and content
func buildSemanticSearchResult(db *gorm.DB, embedding models.ContentEmbedding, similarity float64) (SemanticSearchResult, error) {
result := SemanticSearchResult{
Similarity: similarity,
}
switch embedding.ContentType {
case "bookmark":
var bookmark models.Bookmark
if err := db.Preload("Tags").First(&bookmark, embedding.ContentID).Error; err != nil {
return result, err
}
result.ID = bookmark.ID
result.Type = "bookmark"
result.Title = bookmark.Title
result.Description = bookmark.Description
result.Content = bookmark.Content
result.Tags = bookmark.Tags
result.CreatedAt = bookmark.CreatedAt
result.UpdatedAt = bookmark.UpdatedAt
result.URL = bookmark.URL
case "task":
var task models.Task
if err := db.Preload("Tags").First(&task, embedding.ContentID).Error; err != nil {
return result, err
}
result.ID = task.ID
result.Type = "task"
result.Title = task.Title
result.Description = task.Description
result.Tags = task.Tags
result.CreatedAt = task.CreatedAt
result.UpdatedAt = task.UpdatedAt
result.Status = string(task.Status)
result.Priority = string(task.Priority)
case "note":
var note models.Note
if err := db.Preload("Tags").First(&note, embedding.ContentID).Error; err != nil {
return result, err
}
result.ID = note.ID
result.Type = "note"
result.Title = note.Title
result.Description = note.Description
result.Content = note.Content
result.Tags = note.Tags
result.CreatedAt = note.CreatedAt
result.UpdatedAt = note.UpdatedAt
case "file":
var file models.File
if err := db.Preload("Tags").First(&file, embedding.ContentID).Error; err != nil {
return result, err
}
result.ID = file.ID
result.Type = "file"
result.Title = file.OriginalName
result.Description = file.Description
result.Content = file.Content
result.Tags = file.Tags
result.CreatedAt = file.CreatedAt
result.UpdatedAt = file.UpdatedAt
case "calendar_event":
var event models.CalendarEvent
if err := db.First(&event, embedding.ContentID).Error; err != nil {
return result, err
}
result.ID = event.ID
result.Type = "calendar_event"
result.Title = event.Title
result.Description = event.Description
result.Content = event.Description
result.CreatedAt = event.CreatedAt
result.UpdatedAt = event.UpdatedAt
result.Priority = event.Priority
case "youtube_video":
var video models.VideoBookmark
if err := db.First(&video, embedding.ContentID).Error; err != nil {
return result, err
}
result.ID = video.ID
result.Type = "youtube_video"
result.Title = video.Title
result.Description = video.Description
result.Content = video.Description
result.CreatedAt = video.CreatedAt
result.UpdatedAt = video.UpdatedAt
result.URL = video.URL
case "learning_path":
var path models.LearningPath
if err := db.First(&path, embedding.ContentID).Error; err != nil {
return result, err
}
result.ID = path.ID
result.Type = "learning_path"
result.Title = path.Title
result.Description = path.Description
result.Content = path.Description
result.CreatedAt = path.CreatedAt
result.UpdatedAt = path.UpdatedAt
case "chat_message":
var message models.Message
if err := db.First(&message, embedding.ContentID).Error; err != nil {
return result, err
}
if message.IsSensitive {
return result, fmt.Errorf("sensitive message excluded from semantic search")
}
result.ID = message.ID
result.Type = "chat_message"
result.Title = "Chat message"
result.Description = compactSemanticText(message.Body, 140)
result.Content = message.Body
result.CreatedAt = message.CreatedAt
result.UpdatedAt = message.UpdatedAt
result.URL = fmt.Sprintf("/app/messages?conversationId=%d&messageId=%d", message.ConversationID, message.ID)
}
// Generate highlights (simplified)
result.Highlights = generateHighlights(embedding.TextContent, 3)
return result, nil
}
// generateHighlights generates text highlights
func generateHighlights(text string, count int) []string {
if text == "" {
return []string{}
}
// Simple highlight generation - split into sentences and return first few
sentences := strings.Split(text, ".")
if len(sentences) > count {
sentences = sentences[:count]
}
var highlights []string
for _, sentence := range sentences {
sentence = strings.TrimSpace(sentence)
if len(sentence) > 10 {
highlights = append(highlights, sentence+".")
}
if len(highlights) >= count {
break
}
}
return highlights
}
// reindexUserContent reindexes all content for a user
func reindexUserContent(db *gorm.DB, userID uint) {
log.Printf("Starting reindexing for user %d", userID)
// Reindex bookmarks
var bookmarks []models.Bookmark
db.Where("user_id = ?", userID).Find(&bookmarks)
for _, bookmark := range bookmarks {
text := bookmark.Title + " " + bookmark.Description + " " + bookmark.Content
upsertEmbedding(db, userID, "bookmark", bookmark.ID, text)
}
// Tasks
var tasks []models.Task
db.Where("user_id = ?", userID).Find(&tasks)
for _, task := range tasks {
text := task.Title + " " + task.Description
upsertEmbedding(db, userID, "task", task.ID, text)
}
// Notes
var notes []models.Note
db.Where("user_id = ?", userID).Find(&notes)
for _, note := range notes {
if note.IsEncrypted {
continue
}
text := note.Title + " " + note.Description + " " + note.Content
upsertEmbedding(db, userID, "note", note.ID, text)
}
// Files
var files []models.File
db.Where("user_id = ?", userID).Find(&files)
for _, file := range files {
text := file.OriginalName + " " + file.Description + " " + file.Content
upsertEmbedding(db, userID, "file", file.ID, text)
}
// Calendar events
var events []models.CalendarEvent
db.Where("user_id = ?", userID).Find(&events)
for _, event := range events {
text := event.Title + " " + event.Description + " " + event.Type + " " + event.Priority
upsertEmbedding(db, userID, "calendar_event", event.ID, text)
}
// YouTube bookmarks
var videos []models.VideoBookmark
db.Where("user_id = ?", userID).Find(&videos)
for _, video := range videos {
text := video.Title + " " + video.Description + " " + video.Channel + " " + video.URL
upsertEmbedding(db, userID, "youtube_video", video.ID, text)
}
// Learning paths
var learningPaths []models.LearningPath
db.Where("creator_id = ?", userID).Find(&learningPaths)
for _, path := range learningPaths {
text := path.Title + " " + path.Description + " " + path.Category + " " + path.Difficulty
upsertEmbedding(db, userID, "learning_path", path.ID, text)
}
// Chat messages (skip sensitive/vault content)
var messages []models.Message
db.Model(&models.Message{}).
Joins("JOIN conversation_members cm ON cm.conversation_id = messages.conversation_id").
Joins("JOIN conversations ON conversations.id = messages.conversation_id").
Where("cm.user_id = ?", userID).
Where("conversations.type <> ?", models.ConversationTypePasswordVault).
Where("messages.deleted_at IS NULL").
Find(&messages)
for _, message := range messages {
if message.IsSensitive {
continue
}
upsertEmbedding(db, userID, "chat_message", message.ID, message.Body)
}
log.Printf("Reindexing completed for user %d", userID)
}
func upsertEmbedding(db *gorm.DB, userID uint, contentType string, contentID uint, text string) {
text = strings.TrimSpace(text)
if text == "" {
return
}
embedding, err := generateEmbedding(text)
if err != nil {
return
}
embeddingJSON, _ := json.Marshal(embedding)
contentEmbedding := models.ContentEmbedding{
ContentType: contentType,
ContentID: contentID,
Embedding: string(embeddingJSON),
Model: "text-embedding-ada-002",
Dimensions: len(embedding),
TextContent: text,
UserID: userID,
}
db.Where("content_type = ? AND content_id = ? AND user_id = ?", contentType, contentID, userID).Delete(&models.ContentEmbedding{})
db.Create(&contentEmbedding)
}
func normalizeSemanticContentType(contentType string) string {
switch strings.ToLower(strings.TrimSpace(contentType)) {
case "bookmarks":
return "bookmark"
case "tasks":
return "task"
case "notes":
return "note"
case "files":
return "file"
case "calendar_events":
return "calendar_event"
case "youtube_videos":
return "youtube_video"
case "learning_paths":
return "learning_path"
case "chat_messages":
return "chat_message"
default:
return strings.ToLower(strings.TrimSpace(contentType))
}
}
func compactSemanticText(text string, limit int) string {
text = strings.TrimSpace(text)
if len(text) <= limit {
return text
}
if limit < 4 {
return text
}
return strings.TrimSpace(text[:limit-3]) + "..."
}
+18
View File
@@ -2,6 +2,8 @@ package handlers
import ( import (
"net/http" "net/http"
"os"
"strconv"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/trackeep/backend/config" "github.com/trackeep/backend/config"
@@ -98,3 +100,19 @@ func getDefaultUpdateSettings() UpdateSettings {
PrereleaseUpdates: getBoolEnvWithDefault("PRERELEASE_UPDATES", false), 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
View File
@@ -215,7 +215,6 @@ func main() {
goalsHabitsHandler := handlers.NewGoalsHabitsHandler(config.GetDB()) goalsHabitsHandler := handlers.NewGoalsHabitsHandler(config.GetDB())
socialHandler := handlers.NewSocialHandler(config.GetDB()) socialHandler := handlers.NewSocialHandler(config.GetDB())
teamsHandler := handlers.NewTeamsHandler(config.GetDB()) teamsHandler := handlers.NewTeamsHandler(config.GetDB())
aiRecommendationHandler := handlers.NewAIRecommendationHandler(config.GetDB())
marketplaceHandler := handlers.NewMarketplaceHandler(config.GetDB()) marketplaceHandler := handlers.NewMarketplaceHandler(config.GetDB())
communityHandler := handlers.NewCommunityHandler(config.GetDB()) communityHandler := handlers.NewCommunityHandler(config.GetDB())
performanceHandler := handlers.NewPerformanceHandler(config.GetDB()) performanceHandler := handlers.NewPerformanceHandler(config.GetDB())
@@ -256,8 +255,6 @@ func main() {
github.GET("/activity", handlers.GetGitHubActivity) github.GET("/activity", handlers.GetGitHubActivity)
} }
v1.POST("/youtube-search-test", handlers.YouTubeSearchTest)
// Protected auth routes (with demo mode protection) // Protected auth routes (with demo mode protection)
authProtected := v1.Group("/auth") authProtected := v1.Group("/auth")
authProtected.Use(handlers.AuthMiddleware()) authProtected.Use(handlers.AuthMiddleware())
@@ -280,25 +277,13 @@ func main() {
authProtected.POST("/decrypt/content", handlers.DecryptNoteContent) authProtected.POST("/decrypt/content", handlers.DecryptNoteContent)
authProtected.GET("/encryption/status", handlers.GetEncryptionStatus) 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 // Update Settings routes
authProtected.GET("/update/settings", handlers.GetUpdateSettings) authProtected.GET("/update/settings", handlers.GetUpdateSettings)
authProtected.PUT("/update/settings", handlers.UpdateUpdateSettings) 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) // Test update settings without auth (for demo mode)
v1.GET("/test-search-settings", handlers.GetTestSearchSettings)
v1.GET("/test-update-settings", handlers.GetTestUpdateSettings) v1.GET("/test-update-settings", handlers.GetTestUpdateSettings)
// Dashboard routes (protected) // Dashboard routes (protected)
@@ -435,46 +420,6 @@ func main() {
notes.GET("/:id/encrypted", handlers.GetEncryptedNote) 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) // Member routes (protected)
members := v1.Group("/members") members := v1.Group("/members")
members.Use(handlers.AuthMiddleware()) members.Use(handlers.AuthMiddleware())
@@ -515,37 +460,6 @@ func main() {
videoBookmarks.POST("/:id/toggle-favorite", videoBookmarkHandler.ToggleFavorite) 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) // Time tracking routes (protected)
timeEntries := v1.Group("/time-entries") timeEntries := v1.Group("/time-entries")
timeEntries.Use(handlers.AuthMiddleware()) timeEntries.Use(handlers.AuthMiddleware())
@@ -575,30 +489,6 @@ func main() {
calendar.PUT("/:id/toggle-complete", calendarHandler.ToggleEventCompletion) 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) // Integration routes (protected)
integrations := v1.Group("/integrations") integrations := v1.Group("/integrations")
integrations.Use(handlers.AuthMiddleware()) integrations.Use(handlers.AuthMiddleware())
@@ -742,19 +632,6 @@ func main() {
teams.GET("/:id/stats", teamsHandler.GetTeamStats) 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 routes (protected)
marketplace := v1.Group("/marketplace") marketplace := v1.Group("/marketplace")
marketplace.Use(handlers.AuthMiddleware()) marketplace.Use(handlers.AuthMiddleware())
-153
View File
@@ -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
}
-62
View File
@@ -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"`
}
-56
View File
@@ -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"`
}
-200
View File
@@ -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"`
}
-27
View File
@@ -127,8 +127,6 @@ func AutoMigrate() error {
{name: "BrowserExtension", model: &BrowserExtension{}}, {name: "BrowserExtension", model: &BrowserExtension{}},
{name: "TimeEntry", model: &TimeEntry{}}, {name: "TimeEntry", model: &TimeEntry{}},
{name: "FileAnalysis", model: &FileAnalysis{}}, {name: "FileAnalysis", model: &FileAnalysis{}},
{name: "ChatSession", model: &ChatSession{}},
{name: "ChatMessage", model: &ChatMessage{}},
{name: "LearningPath", model: &LearningPath{}}, {name: "LearningPath", model: &LearningPath{}},
{name: "LearningModule", model: &LearningModule{}}, {name: "LearningModule", model: &LearningModule{}},
{name: "ModuleResource", model: &ModuleResource{}}, {name: "ModuleResource", model: &ModuleResource{}},
@@ -139,23 +137,7 @@ func AutoMigrate() error {
{name: "CalendarEvent", model: &CalendarEvent{}}, {name: "CalendarEvent", model: &CalendarEvent{}},
{name: "RecurrenceRule", model: &RecurrenceRule{}}, {name: "RecurrenceRule", model: &RecurrenceRule{}},
{name: "CalendarSettings", model: &CalendarSettings{}}, {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: "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: "Integration", model: &Integration{}},
{name: "SyncLog", model: &SyncLog{}}, {name: "SyncLog", model: &SyncLog{}},
{name: "WebhookEvent", model: &WebhookEvent{}}, {name: "WebhookEvent", model: &WebhookEvent{}},
@@ -208,15 +190,6 @@ func AutoMigrate() error {
{name: "MentorshipRequest", model: &MentorshipRequest{}}, {name: "MentorshipRequest", model: &MentorshipRequest{}},
{name: "YouTubeChannelCache", model: &YouTubeChannelCache{}}, {name: "YouTubeChannelCache", model: &YouTubeChannelCache{}},
{name: "VideoBookmark", model: &VideoBookmark{}}, {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{ criticalModels := map[string]bool{
-111
View File
@@ -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
}
-532
View File
@@ -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
}
-172
View File
@@ -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
View File
@@ -8,7 +8,6 @@ import { Bookmarks } from '@/pages/content/Bookmarks'
import { Tasks } from '@/pages/productivity/Tasks' import { Tasks } from '@/pages/productivity/Tasks'
import { Files } from '@/pages/content/Files' import { Files } from '@/pages/content/Files'
import { Notes } from '@/pages/content/Notes' import { Notes } from '@/pages/content/Notes'
import Chat from '@/pages/communication/Chat'
import { Settings } from '@/pages/settings/Settings' import { Settings } from '@/pages/settings/Settings'
import { Login } from '@/pages/auth/Login' import { Login } from '@/pages/auth/Login'
import { Youtube } from '@/pages/content/Youtube' import { Youtube } from '@/pages/content/Youtube'
@@ -23,11 +22,11 @@ import { LearningPaths } from '@/pages/content/LearningPaths'
import { GitHub } from '@/pages/content/GitHub' import { GitHub } from '@/pages/content/GitHub'
import { TimeTracking } from '@/pages/productivity/TimeTracking' import { TimeTracking } from '@/pages/productivity/TimeTracking'
import { Calendar } from '@/pages/productivity/Calendar' import { Calendar } from '@/pages/productivity/Calendar'
import { WorkspaceSetup } from '@/pages/auth/WorkspaceSetup'
import { AuthCallback } from '@/pages/auth/AuthCallback' import { AuthCallback } from '@/pages/auth/AuthCallback'
import { AuthProvider, useAuth } from '@/lib/auth' import { AuthProvider, useAuth } from '@/lib/auth'
import { Search } from '@/pages/content/Search' import { Search } from '@/pages/content/Search'
import { Analytics } from '@/pages/admin/Analytics' import { Analytics } from '@/pages/admin/Analytics'
import { Messages } from '@/pages/communication/Messages'
import { ShareTarget } from '@/pages/misc/ShareTarget' import { ShareTarget } from '@/pages/misc/ShareTarget'
import BrowserExtensionSettings from '@/pages/settings/BrowserExtensionSettings' import BrowserExtensionSettings from '@/pages/settings/BrowserExtensionSettings'
import { initializeDemoMode, clearDemoMode, isEnvDemoMode } from '@/lib/demo-mode' import { initializeDemoMode, clearDemoMode, isEnvDemoMode } from '@/lib/demo-mode'
@@ -40,29 +39,78 @@ const initializeDarkMode = () => {
const savedTheme = localStorage.getItem('theme'); const savedTheme = localStorage.getItem('theme');
const user = localStorage.getItem('user') || localStorage.getItem('trackeep_user'); 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) { if (user) {
try { try {
const userData = JSON.parse(user); const userData = JSON.parse(user);
// Prefer user's saved theme from profile, fallback to localStorage // Prefer user's saved theme from profile, fallback to localStorage
const userTheme = userData.theme || savedTheme; const userTheme = userData.theme || savedTheme;
if (userTheme === 'dark') { if (userTheme === 'dark') {
document.documentElement.setAttribute('data-kb-theme', 'dark'); root.setAttribute('data-kb-theme', 'dark');
} else { } else {
document.documentElement.removeAttribute('data-kb-theme'); root.removeAttribute('data-kb-theme');
} }
} catch (e) { } catch (e) {
// Fallback to localStorage or dark mode if user data is invalid // Fallback to localStorage or dark mode if user data is invalid
if (savedTheme === 'dark') { if (savedTheme === 'dark') {
document.documentElement.setAttribute('data-kb-theme', 'dark'); root.setAttribute('data-kb-theme', 'dark');
} else { } else {
document.documentElement.removeAttribute('data-kb-theme'); root.removeAttribute('data-kb-theme');
} }
} }
} else if (savedTheme === 'dark') { } else if (savedTheme === 'dark') {
document.documentElement.setAttribute('data-kb-theme', 'dark'); root.setAttribute('data-kb-theme', 'dark');
} else { } else {
// Default to dark mode // 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> </Layout>
</ProtectedRoute> </ProtectedRoute>
)} /> )} />
<Route path="/app/workspace-setup" component={() => (
<ProtectedRoute>
<Layout title="Workspace Setup">
<WorkspaceSetup />
</Layout>
</ProtectedRoute>
)} />
<Route path="/app/bookmarks" component={() => ( <Route path="/app/bookmarks" component={() => (
<ProtectedRoute> <ProtectedRoute>
<Layout title="Bookmarks"> <Layout title="Bookmarks">
@@ -206,20 +261,7 @@ function App() {
</Layout> </Layout>
</ProtectedRoute> </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={() => ( <Route path="/app/members" component={() => (
<ProtectedRoute> <ProtectedRoute>
<Layout title="Members"> <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 -54
View File
@@ -1,8 +1,6 @@
import { children, createSignal, onMount } from 'solid-js' import { children, createSignal, onMount } from 'solid-js'
import { Sidebar } from './Sidebar' import { Sidebar } from './Sidebar'
import { Header } from './Header' import { Header } from './Header'
import { AIChatPanel } from './AIChatPanel'
import { IconBrain } from '@tabler/icons-solidjs'
import { isEnvDemoMode } from '@/lib/demo-mode' import { isEnvDemoMode } from '@/lib/demo-mode'
export interface LayoutProps { export interface LayoutProps {
@@ -14,7 +12,6 @@ export interface LayoutProps {
export function Layout(props: LayoutProps) { export function Layout(props: LayoutProps) {
const resolved = children(() => props.children) const resolved = children(() => props.children)
const [isChatOpen, setIsChatOpen] = createSignal(false)
const [isSidebarOpen, setIsSidebarOpen] = createSignal(true) const [isSidebarOpen, setIsSidebarOpen] = createSignal(true)
onMount(() => { onMount(() => {
@@ -25,7 +22,6 @@ export function Layout(props: LayoutProps) {
setIsSidebarOpen(window.innerWidth >= 768) setIsSidebarOpen(window.innerWidth >= 768)
} }
// Initialize dark mode from localStorage or system preference
const savedTheme = localStorage.getItem('theme') const savedTheme = localStorage.getItem('theme')
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
@@ -38,49 +34,38 @@ export function Layout(props: LayoutProps) {
document.documentElement.removeAttribute('data-kb-theme') document.documentElement.removeAttribute('data-kb-theme')
} }
// Initialize color scheme from localStorage
const savedColorScheme = localStorage.getItem('colorScheme'); const savedColorScheme = localStorage.getItem('colorScheme');
const savedCustomColors = localStorage.getItem('customColors'); const savedCustomColors = localStorage.getItem('customColors');
if (savedColorScheme === 'custom' && savedCustomColors) { if (savedColorScheme === 'custom' && savedCustomColors) {
try { try {
const colors = JSON.parse(savedCustomColors); const colors = JSON.parse(savedCustomColors);
// Apply custom colors
const hexToHsl = (hex: string) => { const hexToHsl = (hex: string) => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
if (!result) return '0 0% 100%'; if (!result) return '0 0% 100%';
let r = parseInt(result[1], 16) / 255; let r = parseInt(result[1], 16) / 255;
let g = parseInt(result[2], 16) / 255; let g = parseInt(result[2], 16) / 255;
let b = parseInt(result[3], 16) / 255; let b = parseInt(result[3], 16) / 255;
const max = Math.max(r, g, b); const max = Math.max(r, g, b);
const min = Math.min(r, g, b); const min = Math.min(r, g, b);
let h = 0, s = 0, l = (max + min) / 2; let h = 0, s = 0, l = (max + min) / 2;
if (max !== min) { if (max !== min) {
const d = max - min; const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min); s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) { switch (max) {
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break; case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
case g: h = ((b - r) / d + 2) / 6; break; case g: h = ((b - r) / d + 2) / 6; break;
case b: h = ((r - g) / d + 4) / 6; break; case b: h = ((r - g) / d + 4) / 6; break;
} }
} }
return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`; return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`;
}; };
const root = document.documentElement; const root = document.documentElement;
root.style.setProperty('--primary', hexToHsl(colors.primary)); root.style.setProperty('--primary', hexToHsl(colors.primary));
root.style.setProperty('--background', hexToHsl(colors.background)); root.style.setProperty('--background', hexToHsl(colors.background));
root.style.setProperty('--foreground', hexToHsl(colors.foreground)); root.style.setProperty('--foreground', hexToHsl(colors.foreground));
root.style.setProperty('--muted', hexToHsl(colors.muted)); root.style.setProperty('--muted', hexToHsl(colors.muted));
root.style.setProperty('--border', colors.border); 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-primary', hexToHsl(colors.primary));
root.style.setProperty('--colors-background', hexToHsl(colors.background)); root.style.setProperty('--colors-background', hexToHsl(colors.background));
root.style.setProperty('--colors-foreground', hexToHsl(colors.foreground)); 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); console.error('Failed to load custom colors:', e);
} }
} else if (savedColorScheme) { } else if (savedColorScheme) {
// Apply predefined scheme
const predefinedSchemes: Record<string, any> = { 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' }, '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' }, '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' }, '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' } 'indigo': { primary: '#6366f1', background: savedTheme === 'dark' ? '#1e1b4b' : '#eef2ff', foreground: savedTheme === 'dark' ? '#ffffff' : '#000000', muted: savedTheme === 'dark' ? '#312e81' : '#e0e7ff', border: '#4338ca' }
}; };
const scheme = predefinedSchemes[savedColorScheme]; const scheme = predefinedSchemes[savedColorScheme];
if (scheme) { if (scheme) {
const hexToHsl = (hex: string) => { const hexToHsl = (hex: string) => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
if (!result) return '0 0% 100%'; if (!result) return '0 0% 100%';
let r = parseInt(result[1], 16) / 255; let r = parseInt(result[1], 16) / 255;
let g = parseInt(result[2], 16) / 255; let g = parseInt(result[2], 16) / 255;
let b = parseInt(result[3], 16) / 255; let b = parseInt(result[3], 16) / 255;
const max = Math.max(r, g, b); const max = Math.max(r, g, b);
const min = Math.min(r, g, b); const min = Math.min(r, g, b);
let h = 0, s = 0, l = (max + min) / 2; let h = 0, s = 0, l = (max + min) / 2;
if (max !== min) { if (max !== min) {
const d = max - min; const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min); s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) { switch (max) {
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break; case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
case g: h = ((b - r) / d + 2) / 6; break; case g: h = ((b - r) / d + 2) / 6; break;
case b: h = ((r - g) / d + 4) / 6; break; case b: h = ((r - g) / d + 4) / 6; break;
} }
} }
return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`; return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`;
}; };
const root = document.documentElement; const root = document.documentElement;
root.style.setProperty('--primary', hexToHsl(scheme.primary)); root.style.setProperty('--primary', hexToHsl(scheme.primary));
root.style.setProperty('--background', hexToHsl(scheme.background)); root.style.setProperty('--background', hexToHsl(scheme.background));
root.style.setProperty('--foreground', hexToHsl(scheme.foreground)); root.style.setProperty('--foreground', hexToHsl(scheme.foreground));
root.style.setProperty('--muted', hexToHsl(scheme.muted)); root.style.setProperty('--muted', hexToHsl(scheme.muted));
root.style.setProperty('--border', scheme.border); 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-primary', hexToHsl(scheme.primary));
root.style.setProperty('--colors-background', hexToHsl(scheme.background)); root.style.setProperty('--colors-background', hexToHsl(scheme.background));
root.style.setProperty('--colors-foreground', hexToHsl(scheme.foreground)); root.style.setProperty('--colors-foreground', hexToHsl(scheme.foreground));
@@ -149,10 +124,6 @@ export function Layout(props: LayoutProps) {
} }
}) })
const toggleChat = () => {
setIsChatOpen(!isChatOpen())
}
const toggleSidebar = () => { const toggleSidebar = () => {
const nextValue = !isSidebarOpen() const nextValue = !isSidebarOpen()
setIsSidebarOpen(nextValue) setIsSidebarOpen(nextValue)
@@ -166,43 +137,19 @@ export function Layout(props: LayoutProps) {
return ( return (
<div class="min-h-screen font-sans text-sm font-400 bg-background text-foreground"> <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"> <div class="flex flex-row h-screen min-h-0 relative">
{/* Mobile Sidebar Overlay */}
{isSidebarOpen() && ( {isSidebarOpen() && (
<div <div class="fixed inset-0 bg-black/50 z-40 md:hidden" onClick={closeSidebar} />
class="fixed inset-0 bg-black/50 z-40 md:hidden"
onClick={closeSidebar}
/>
)} )}
{/* Sidebar */}
<Sidebar isOpen={isSidebarOpen()} onClose={closeSidebar} /> <Sidebar isOpen={isSidebarOpen()} onClose={closeSidebar} />
{/* Main Content */}
<div class="flex-1 min-h-0 flex flex-col"> <div class="flex-1 min-h-0 flex flex-col">
{/* Header */}
{!props.fullBleed && <Header title={props.title} onMenuClick={toggleSidebar} />} {!props.fullBleed && <Header title={props.title} onMenuClick={toggleSidebar} />}
{/* Page Content */}
<main class={`flex-1 ${props.fullBleed ? 'overflow-hidden' : 'overflow-auto w-full'}`}> <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"}> <div class={props.fullBleed ? "h-full" : "p-2 max-w-7xl mx-auto"}>
{resolved()} {resolved()}
</div> </div>
</main> </main>
</div> </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>
</div> </div>
) )
+238 -57
View File
@@ -12,16 +12,21 @@ import {
IconChevronDown, IconChevronDown,
IconTrash, IconTrash,
IconUsers, IconUsers,
IconBrain,
IconSchool, IconSchool,
IconChartLine, IconChartLine,
IconBrandGithub, IconBrandGithub,
IconClock, IconClock,
IconCalendar, IconCalendar,
IconMessageCircle,
IconLogout, IconLogout,
IconBuilding, IconBuilding,
IconPlus IconPlus,
IconWorld,
IconLock,
IconCheck,
IconStar,
IconHeart,
IconBriefcase,
IconEdit
} from '@tabler/icons-solidjs' } from '@tabler/icons-solidjs'
import { Input } from '../ui/Input' import { Input } from '../ui/Input'
import { Button } from '../ui/Button' import { Button } from '../ui/Button'
@@ -39,13 +44,11 @@ const navigation = [
{ name: 'Calendar', href: '/app/calendar', icon: IconCalendar }, { name: 'Calendar', href: '/app/calendar', icon: IconCalendar },
{ name: 'Files', href: '/app/files', icon: IconFolder }, { name: 'Files', href: '/app/files', icon: IconFolder },
{ name: 'Notes', href: '/app/notes', icon: IconNotebook }, { name: 'Notes', href: '/app/notes', icon: IconNotebook },
{ name: 'Messages', href: '/app/messages', icon: IconMessageCircle },
{ name: 'YouTube', href: '/app/youtube', icon: IconVideo }, { name: 'YouTube', href: '/app/youtube', icon: IconVideo },
{ name: 'Members', href: '/app/members', icon: IconUsers }, { name: 'Members', href: '/app/members', icon: IconUsers },
{ name: 'Learning', href: '/app/learning-paths', icon: IconSchool }, { name: 'Learning', href: '/app/learning-paths', icon: IconSchool },
{ name: 'Stats', href: '/app/stats', icon: IconChartLine }, { name: 'Stats', href: '/app/stats', icon: IconChartLine },
{ name: 'GitHub', href: '/app/github', icon: IconBrandGithub }, { name: 'GitHub', href: '/app/github', icon: IconBrandGithub },
{ name: 'AI Assistant', href: '/app/chat', icon: IconBrain },
] ]
const API_BASE_URL = getApiV1BaseUrl() const API_BASE_URL = getApiV1BaseUrl()
@@ -55,13 +58,25 @@ interface WorkspaceOption {
id: string id: string
name: string name: string
icon: typeof IconFileText icon: typeof IconFileText
iconId?: string
description?: string
is_public?: boolean
} }
const getWorkspaceIcon = (name: string) => { const WORKSPACE_ICONS = [
const lower = name.toLowerCase() { id: 'building', icon: IconBuilding },
if (lower.includes('team')) return IconUsers { id: 'world', icon: IconWorld },
if (lower.includes('personal')) return IconBuilding { id: 'lock', icon: IconLock },
return IconFileText { 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') || '' const getAuthToken = () => localStorage.getItem('trackeep_token') || localStorage.getItem('token') || ''
@@ -85,6 +100,14 @@ export function Sidebar(props: SidebarProps) {
const [workspaceIsPublic, setWorkspaceIsPublic] = createSignal(false) const [workspaceIsPublic, setWorkspaceIsPublic] = createSignal(false)
const [isCreatingWorkspace, setIsCreatingWorkspace] = createSignal(false) const [isCreatingWorkspace, setIsCreatingWorkspace] = createSignal(false)
const [createWorkspaceError, setCreateWorkspaceError] = createSignal('') 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 selectedWorkspace = () => {
const list = workspaces() const list = workspaces()
@@ -140,48 +163,27 @@ export function Sidebar(props: SidebarProps) {
setIsWorkspaceDropdownOpen(!isWorkspaceDropdownOpen()) 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 name = team.name?.trim() || DEFAULT_WORKSPACE_NAME
const iconId = team.icon_id || 'building'
return { return {
id: String(team.id ?? `workspace-${Date.now()}`), id: String(team.id ?? `workspace-${Date.now()}`),
name, 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 loadWorkspaces = async () => {
const token = getAuthToken() const token = getAuthToken()
if (!token) { if (!token) {
const fallbackWorkspace = { const fallbackWorkspace = {
id: 'local-default', id: 'local-default',
name: DEFAULT_WORKSPACE_NAME, name: DEFAULT_WORKSPACE_NAME,
icon: IconFileText, icon: IconBuilding,
iconId: 'building',
} }
setWorkspaces([fallbackWorkspace]) setWorkspaces([fallbackWorkspace])
setSelectedWorkspaceId(fallbackWorkspace.id) setSelectedWorkspaceId(fallbackWorkspace.id)
@@ -204,20 +206,11 @@ export function Sidebar(props: SidebarProps) {
} }
if (mappedWorkspaces.length === 0) { if (mappedWorkspaces.length === 0) {
const created = await createDefaultWorkspace(token) const currentPath = window.location.pathname
if (created) { if (currentPath === '/app' || currentPath === '/app/') {
mappedWorkspaces = [created] window.location.href = '/app/workspace-setup'
} }
} return
if (mappedWorkspaces.length === 0) {
mappedWorkspaces = [
{
id: 'local-default',
name: DEFAULT_WORKSPACE_NAME,
icon: IconFileText,
},
]
} }
setWorkspaces(mappedWorkspaces) setWorkspaces(mappedWorkspaces)
@@ -233,7 +226,8 @@ export function Sidebar(props: SidebarProps) {
const fallbackWorkspace = { const fallbackWorkspace = {
id: 'local-default', id: 'local-default',
name: DEFAULT_WORKSPACE_NAME, name: DEFAULT_WORKSPACE_NAME,
icon: IconFileText, icon: IconBuilding,
iconId: 'building',
} }
setWorkspaces([fallbackWorkspace]) setWorkspaces([fallbackWorkspace])
setSelectedWorkspaceId(fallbackWorkspace.id) setSelectedWorkspaceId(fallbackWorkspace.id)
@@ -258,7 +252,8 @@ export function Sidebar(props: SidebarProps) {
const localWorkspace = { const localWorkspace = {
id: `local-${Date.now()}`, id: `local-${Date.now()}`,
name: trimmed, name: trimmed,
icon: getWorkspaceIcon(trimmed), icon: getWorkspaceIcon('building'),
iconId: 'building',
} }
setWorkspaces((prev) => [localWorkspace, ...prev]) setWorkspaces((prev) => [localWorkspace, ...prev])
handleWorkspaceSelect(localWorkspace) 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 // Close dropdown when clicking outside
onMount(() => { onMount(() => {
void loadWorkspaces() void loadWorkspaces()
@@ -375,22 +451,31 @@ export function Sidebar(props: SidebarProps) {
<div class="p-1" role="listbox"> <div class="p-1" role="listbox">
<For each={workspaces()}> <For each={workspaces()}>
{(workspace) => ( {(workspace) => (
<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 <button
type="button" type="button"
onClick={() => handleWorkspaceSelect(workspace)} 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" class="flex flex-1 items-center gap-2 text-sm focus:outline-none"
role="option" role="option"
classList={{ "bg-accent/30": workspace.id === selectedWorkspace().id }}
> >
{(() => { {(() => {
const Icon = workspace.icon const Icon = workspace.icon
return <Icon class="size-4 text-muted-foreground" /> return <Icon class="size-4 text-muted-foreground" />
})()} })()}
<span class="flex-1 text-left truncate">{workspace.name}</span> <span class="text-left truncate">{workspace.name}</span>
<Show when={workspace.id === selectedWorkspace().id}> <Show when={workspace.id === selectedWorkspace().id}>
<div class="w-2 h-2 bg-primary rounded-full"></div> <div class="w-2 h-2 bg-primary rounded-full"></div>
</Show> </Show>
</button> </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> </For>
<div class="border-t border-border mt-1 pt-1"> <div class="border-t border-border mt-1 pt-1">
@@ -596,6 +681,102 @@ export function Sidebar(props: SidebarProps) {
</> </>
</ModalPortal> </ModalPortal>
</Show> </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 { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { ConfirmModal } from '@/components/ui/ConfirmModal';
interface SavedSearch { interface SavedSearch {
id: number; id: number;
@@ -47,6 +48,8 @@ export const SavedSearches = () => {
const [loading, setLoading] = createSignal(false); const [loading, setLoading] = createSignal(false);
const [showCreateModal, setShowCreateModal] = createSignal(false); const [showCreateModal, setShowCreateModal] = createSignal(false);
const [editingSearch, setEditingSearch] = createSignal<SavedSearch | null>(null); const [editingSearch, setEditingSearch] = createSignal<SavedSearch | null>(null);
const [showDeleteModal, setShowDeleteModal] = createSignal(false);
const [searchToDelete, setSearchToDelete] = createSignal<number | null>(null);
const [formData, setFormData] = createSignal<SavedSearchFormData>({ const [formData, setFormData] = createSignal<SavedSearchFormData>({
name: '', name: '',
query: '', query: '',
@@ -136,9 +139,13 @@ export const SavedSearches = () => {
// Delete saved search // Delete saved search
const deleteSavedSearch = async (id: number) => { const deleteSavedSearch = async (id: number) => {
if (!confirm('Are you sure you want to delete this saved search?')) { setSearchToDelete(id);
return; setShowDeleteModal(true);
} };
const confirmDeleteSavedSearch = async () => {
const id = searchToDelete();
if (!id) return;
try { try {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
@@ -151,6 +158,8 @@ export const SavedSearches = () => {
if (response.ok) { if (response.ok) {
loadSavedSearches(); loadSavedSearches();
setShowDeleteModal(false);
setSearchToDelete(null);
} }
} catch (error) { } catch (error) {
console.error('Failed to delete saved search:', error); console.error('Failed to delete saved search:', error);
@@ -479,6 +488,19 @@ export const SavedSearches = () => {
</Card> </Card>
</div> </div>
</Show> </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> </div>
); );
}; };
@@ -81,13 +81,16 @@ export const ColorSwitcherDropdown = () => {
root.style.setProperty('--primary', hslColor); root.style.setProperty('--primary', hslColor);
root.style.setProperty('--colors-primary', hslColor); root.style.setProperty('--colors-primary', hslColor);
// Ensure background stays theme-appropriate
if (isDark) { if (isDark) {
root.style.setProperty('--background', '0 0% 10%'); root.style.setProperty('--background', '0 0% 10%');
root.style.setProperty('--colors-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 { } else {
root.style.setProperty('--background', '0 0% 100%'); root.style.setProperty('--background', '0 0% 100%');
root.style.setProperty('--colors-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) { if (closeDropdown) {
+5 -3
View File
@@ -22,9 +22,10 @@ const Switch = (props: SwitchProps) => {
type="button" type="button"
role="switch" role="switch"
aria-checked={local.checked} aria-checked={local.checked}
data-state={local.checked ? 'checked' : 'unchecked'}
class={cn( 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', '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 ? 'data-[state=checked]' : 'data-[state=unchecked]', local.checked ? 'bg-primary' : 'bg-input',
props.class props.class
)} )}
onClick={handleClick} onClick={handleClick}
@@ -33,7 +34,8 @@ const Switch = (props: SwitchProps) => {
<span <span
data-state={local.checked ? 'checked' : 'unchecked'} data-state={local.checked ? 'checked' : 'unchecked'}
class={cn( 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> </button>
+19 -7
View File
@@ -1,6 +1,7 @@
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { ModalPortal } from '@/components/ui/ModalPortal'; 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 { NoteContentRenderer } from '@/components/notes/NoteContentRenderer';
import { IconX, IconEdit, IconPin, IconTrash, IconCopy, IconDownload, IconPaperclip } from '@tabler/icons-solidjs'; 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); props.onUpdateNote(props.note.id, nextContent);
}; };
const [showDeleteModal, setShowDeleteModal] = createSignal(false);
return ( return (
<ModalPortal> <ModalPortal>
<> <>
@@ -135,12 +138,7 @@ export const ViewNoteModal = (props: ViewNoteModalProps) => {
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
onClick={() => { onClick={() => setShowDeleteModal(true)}
if (confirm('Are you sure you want to delete this note?')) {
props.onDelete(props.note!.id);
props.onClose();
}
}}
class="text-red-400 hover:text-red-300 p-1" class="text-red-400 hover:text-red-300 p-1"
> >
<IconTrash size={18} /> <IconTrash size={18} />
@@ -212,6 +210,20 @@ export const ViewNoteModal = (props: ViewNoteModalProps) => {
</div> </div>
</Show> </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> </ModalPortal>
); );
}; };
-105
View File
@@ -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 [];
}
}
-20
View File
@@ -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 = () => ({ const generateMockEmailSettings = () => ({
smtp_enabled: false, smtp_enabled: false,
smtp_host: '', 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 (path.includes('/api/v1/auth/email/settings')) {
if (method === 'GET') { if (method === 'GET') {
return jsonResponse(generateMockEmailSettings()); return jsonResponse(generateMockEmailSettings());
+141
View File
@@ -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');
},
};
+2
View File
@@ -138,6 +138,8 @@ export const Login = () => {
fullName: formData().fullName, fullName: formData().fullName,
}; };
await register(registerPayload); await register(registerPayload);
navigate('/app/workspace-setup', { replace: true });
return;
} }
navigate(getSafeNextPath(), { replace: true }); navigate(getSafeNextPath(), { replace: true });
} catch (err) { } catch (err) {
+146
View File
@@ -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>
)
}
-596
View File
@@ -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 -15
View File
@@ -3,7 +3,6 @@ import { getApiOrigin } from '@/lib/api-url'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input' import { Input } from '@/components/ui/Input'
import { Card } from '@/components/ui/Card' import { Card } from '@/components/ui/Card'
import { AIProviderIcon } from '@/components/AIProviderIcon'
import { import {
Send, Send,
CheckSquare, CheckSquare,
@@ -736,11 +735,7 @@ const Chat = () => {
{message.role === 'user' ? ( {message.role === 'user' ? (
<User class="w-4 h-4 text-xs" /> <User class="w-4 h-4 text-xs" />
) : ( ) : (
<AIProviderIcon <Sparkles class="w-4 h-4 text-primary animate-pulse" />
providerId={selectedModel()}
size="1rem"
class="text-primary animate-pulse"
/>
)} )}
</div> </div>
<div class="flex-1"> <div class="flex-1">
@@ -771,11 +766,7 @@ const Chat = () => {
onClick={() => setShowModelPicker(!showModelPicker())} 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" 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 <Sparkles class="w-4 h-4 transition-transform hover:scale-110" />
providerId={selectedModel()}
size="1rem"
class="transition-transform hover:scale-110"
/>
<span class="text-sm font-medium"> <span class="text-sm font-medium">
{getAIModels().find(m => m.id === selectedModel())?.name || 'Select Model'} {getAIModels().find(m => m.id === selectedModel())?.name || 'Select Model'}
</span> </span>
@@ -803,10 +794,7 @@ const Chat = () => {
> >
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-3 flex-1"> <div class="flex items-center gap-3 flex-1">
<AIProviderIcon <Sparkles class="w-4 h-4" />
providerId={model.id}
size="1rem"
/>
<div class="flex-1"> <div class="flex-1">
<div class="font-medium text-sm">{model.name}</div> <div class="font-medium text-sm">{model.name}</div>
<div class="text-xs text-muted-foreground mt-1">{model.description}</div> <div class="text-xs text-muted-foreground mt-1">{model.description}</div>
File diff suppressed because it is too large Load Diff
+30 -3
View File
@@ -4,6 +4,7 @@ import { Button } from '@/components/ui/Button';
import { BookmarkModal } from '@/components/ui/BookmarkModal'; import { BookmarkModal } from '@/components/ui/BookmarkModal';
import { EditBookmarkModal } from '@/components/ui/EditBookmarkModal'; import { EditBookmarkModal } from '@/components/ui/EditBookmarkModal';
import { VideoUploadModal } from '@/components/ui/VideoUploadModal'; import { VideoUploadModal } from '@/components/ui/VideoUploadModal';
import { ConfirmModal } from '@/components/ui/ConfirmModal';
import { DropdownMenu, DropdownMenuItem } from '@/components/ui/DropdownMenu'; import { DropdownMenu, DropdownMenuItem } from '@/components/ui/DropdownMenu';
import { SearchTagFilterBar } from '@/components/ui/SearchTagFilterBar'; import { SearchTagFilterBar } from '@/components/ui/SearchTagFilterBar';
import { IconDotsVertical, IconStar, IconEdit, IconTrash, IconExternalLink, IconVideo, IconBookmark } from '@tabler/icons-solidjs'; import { IconDotsVertical, IconStar, IconEdit, IconTrash, IconExternalLink, IconVideo, IconBookmark } from '@tabler/icons-solidjs';
@@ -267,8 +268,19 @@ export const Bookmarks = () => {
haptics.selection(); // Selection feedback for starring 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) => { const deleteBookmark = async (bookmarkId: number) => {
if (confirm('Are you sure you want to delete this bookmark?')) { setBookmarkToDelete(bookmarkId);
setShowDeleteModal(true);
};
const confirmDeleteBookmark = async () => {
const bookmarkId = bookmarkToDelete();
if (!bookmarkId) return;
try { try {
const response = await fetch(`${API_BASE_URL}/bookmarks/${bookmarkId}`, { const response = await fetch(`${API_BASE_URL}/bookmarks/${bookmarkId}`, {
method: 'DELETE', method: 'DELETE',
@@ -284,10 +296,11 @@ export const Bookmarks = () => {
setBookmarks(prev => prev.filter(bookmark => bookmark.id !== bookmarkId)); setBookmarks(prev => prev.filter(bookmark => bookmark.id !== bookmarkId));
haptics.delete(); // Delete feedback haptics.delete(); // Delete feedback
setShowDeleteModal(false);
setBookmarkToDelete(null);
} catch (error) { } catch (error) {
haptics.error(); // Error feedback haptics.error(); // Error feedback
alert(error instanceof Error ? error.message : 'Failed to delete bookmark'); setModalError(error instanceof Error ? error.message : 'Failed to delete bookmark');
}
} }
}; };
@@ -774,6 +787,20 @@ export const Bookmarks = () => {
onClose={() => setShowVideoModal(false)} onClose={() => setShowVideoModal(false)}
onSubmit={handleVideoSubmit} 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> </div>
); );
}; };
+1 -40
View File
@@ -24,12 +24,10 @@ import {
IconChartLine, IconChartLine,
IconActivity, IconActivity,
IconSearch, IconSearch,
IconChevronDown,
IconVideo, IconVideo,
IconSchool, IconSchool,
IconX IconX
} from '@tabler/icons-solidjs'; } from '@tabler/icons-solidjs';
import { BrowserSearch } from '@/components/search/BrowserSearch';
import { DropdownMenu, DropdownMenuItem } from '@/components/ui/DropdownMenu'; import { DropdownMenu, DropdownMenuItem } from '@/components/ui/DropdownMenu';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
import { FilePreviewModal } from '@/components/ui/FilePreviewModal'; import { FilePreviewModal } from '@/components/ui/FilePreviewModal';
@@ -210,7 +208,7 @@ export const Dashboard = () => {
const [documents, setDocuments] = createSignal<Document[]>([]); const [documents, setDocuments] = createSignal<Document[]>([]);
const [, setRecentActivity] = createSignal<RecentActivity[]>([]); const [, setRecentActivity] = createSignal<RecentActivity[]>([]);
const [githubActivityEvents, setGithubActivityEvents] = createSignal<GitHubActivityEvent[]>([]); const [githubActivityEvents, setGithubActivityEvents] = createSignal<GitHubActivityEvent[]>([]);
const [showBrowserSearch, setShowBrowserSearch] = createSignal(true);
const [showFilePreview, setShowFilePreview] = createSignal(false); const [showFilePreview, setShowFilePreview] = createSignal(false);
const [selectedFile, setSelectedFile] = createSignal<Document | null>(null); const [selectedFile, setSelectedFile] = createSignal<Document | null>(null);
const [currentPage, setCurrentPage] = createSignal(1); const [currentPage, setCurrentPage] = createSignal(1);
@@ -293,9 +291,6 @@ export const Dashboard = () => {
}; };
onMount(async () => { onMount(async () => {
// Load browser search setting from localStorage
setShowBrowserSearch(localStorage.getItem('showBrowserSearch') !== 'false');
if (isDemoMode()) { if (isDemoMode()) {
setDashboardStats(getMockStats()); setDashboardStats(getMockStats());
setDocuments(getMockDocuments()); setDocuments(getMockDocuments());
@@ -961,40 +956,6 @@ export const Dashboard = () => {
<Show when={isSearchAvailable()}> <Show when={isSearchAvailable()}>
<div class="mb-8"> <div class="mb-8">
<div class="border rounded-lg"> <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>
</div> </div>
</Show> </Show>
+40 -12
View File
@@ -1,5 +1,6 @@
import { createEffect, createSignal, onMount, Show } from 'solid-js'; import { createEffect, createSignal, onMount, Show } from 'solid-js';
import { IconTrash, IconRestore, IconFileText, IconFileTypePpt, IconFileTypeDocx, IconClock, IconSettings, IconAlertTriangle } from '@tabler/icons-solidjs'; 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'; import { getApiV1BaseUrl } from '@/lib/api-url';
interface RemovedItem { interface RemovedItem {
@@ -31,6 +32,12 @@ export const RemovedStuff = () => {
const [showSettings, setShowSettings] = createSignal(false); const [showSettings, setShowSettings] = createSignal(false);
const [selectedItems, setSelectedItems] = createSignal<string[]>([]); const [selectedItems, setSelectedItems] = createSignal<string[]>([]);
const [loadedRemovedItems, setLoadedRemovedItems] = createSignal(false); 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(() => { createEffect(() => {
if (!loadedRemovedItems()) return; if (!loadedRemovedItems()) return;
@@ -167,48 +174,59 @@ export const RemovedStuff = () => {
}; };
const handleEmptyTrash = () => { const handleEmptyTrash = () => {
if (confirm('Are you sure you want to permanently delete all items in the trash? This action cannot be undone.')) { 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([]); setRemovedItems([]);
alert('Trash emptied successfully!'); setShowDeleteModal(false);
} }
});
setShowDeleteModal(true);
}; };
const handleRestoreItem = (id: string) => { const handleRestoreItem = (id: string) => {
const item = removedItems().find(item => item.id === id); const item = removedItems().find(item => item.id === id);
if (item) { if (item) {
setRemovedItems(prev => prev.filter(item => item.id !== id)); setRemovedItems(prev => prev.filter(item => item.id !== id));
alert(`"${item.name}" has been restored successfully!`);
} }
}; };
const handlePermanentlyDelete = (id: string) => { const handlePermanentlyDelete = (id: string) => {
const item = removedItems().find(item => item.id === id); 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.`)) { 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)); setRemovedItems(prev => prev.filter(item => item.id !== id));
alert(`"${item.name}" has been permanently deleted!`); setShowDeleteModal(false);
}
});
setShowDeleteModal(true);
} }
}; };
const handleBulkRestore = () => { const handleBulkRestore = () => {
if (selectedItems().length === 0) return; 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))); setRemovedItems(prev => prev.filter(item => !selectedItems().includes(item.id)));
setSelectedItems([]); setSelectedItems([]);
alert(`${itemsToRestore.length} items have been restored successfully!`);
}
}; };
const handleBulkDelete = () => { const handleBulkDelete = () => {
if (selectedItems().length === 0) return; if (selectedItems().length === 0) return;
if (confirm(`Are you sure you want to permanently delete ${selectedItems().length} items? This action cannot be undone.`)) { setDeleteModalConfig({
const itemsToDelete = removedItems().filter(item => selectedItems().includes(item.id)); 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))); setRemovedItems(prev => prev.filter(item => !selectedItems().includes(item.id)));
setSelectedItems([]); setSelectedItems([]);
alert(`${itemsToDelete.length} items have been permanently deleted!`); setShowDeleteModal(false);
} }
});
setShowDeleteModal(true);
}; };
const getItemsReadyForAutoRemove = () => { const getItemsReadyForAutoRemove = () => {
@@ -442,6 +460,16 @@ export const RemovedStuff = () => {
</tbody> </tbody>
</table> </table>
</div> </div>
<ConfirmModal
isOpen={showDeleteModal()}
onClose={() => setShowDeleteModal(false)}
onConfirm={() => deleteModalConfig().onConfirm()}
title={deleteModalConfig().title}
message={deleteModalConfig().message}
confirmText="Delete"
type="danger"
/>
</div> </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>
);
};
+29 -3
View File
@@ -2,6 +2,7 @@ import { createSignal, onMount } from 'solid-js';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { TaskModal } from '@/components/ui/TaskModal'; import { TaskModal } from '@/components/ui/TaskModal';
import { ConfirmModal } from '@/components/ui/ConfirmModal';
import { IconEdit, IconTrash } from '@tabler/icons-solidjs'; import { IconEdit, IconTrash } from '@tabler/icons-solidjs';
import { getApiV1BaseUrl } from '@/lib/api-url'; import { getApiV1BaseUrl } from '@/lib/api-url';
import { useHaptics } from '@/lib/haptics'; import { useHaptics } from '@/lib/haptics';
@@ -28,6 +29,9 @@ export const Tasks = () => {
const [selectedPriority, setSelectedPriority] = createSignal(''); const [selectedPriority, setSelectedPriority] = createSignal('');
const [draggedTaskId, setDraggedTaskId] = createSignal<number | null>(null); const [draggedTaskId, setDraggedTaskId] = createSignal<number | null>(null);
const [dragOverColumn, setDragOverColumn] = createSignal<string | 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 [taskStatuses, setTaskStatuses] = createSignal<Record<number, 'todo' | 'inProgress' | 'done'>>({});
const haptics = useHaptics(); const haptics = useHaptics();
@@ -174,7 +178,14 @@ export const Tasks = () => {
}; };
const deleteTask = async (taskId: number) => { const deleteTask = async (taskId: number) => {
if (confirm('Are you sure you want to delete this task?')) { setTaskToDelete(taskId);
setShowDeleteModal(true);
};
const confirmDeleteTask = async () => {
const taskId = taskToDelete();
if (!taskId) return;
try { try {
const response = await fetch(`${API_BASE_URL}/tasks/${taskId}`, { const response = await fetch(`${API_BASE_URL}/tasks/${taskId}`, {
method: 'DELETE', method: 'DELETE',
@@ -190,10 +201,11 @@ export const Tasks = () => {
setTasks(prev => prev.filter(task => task.id !== taskId)); setTasks(prev => prev.filter(task => task.id !== taskId));
haptics.delete(); // Delete feedback haptics.delete(); // Delete feedback
setShowDeleteModal(false);
setTaskToDelete(null);
} catch (error) { } catch (error) {
haptics.error(); // Error feedback haptics.error(); // Error feedback
alert(error instanceof Error ? error.message : 'Failed to delete task'); setModalError(error instanceof Error ? error.message : 'Failed to delete task');
}
} }
}; };
@@ -342,6 +354,20 @@ export const Tasks = () => {
})} })}
</div> </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> </div>
); );
}; };
+177 -148
View File
@@ -1,204 +1,233 @@
import { createSignal, createEffect, onMount } from 'solid-js'; 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 { IconClock, IconActivity, IconCurrencyDollar } from '@tabler/icons-solidjs';
import { isDemoMode } from '@/lib/demo-mode';
import { useHaptics } from '@/lib/haptics'; 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 = () => { export const TimeTracking = () => {
const haptics = useHaptics(); const haptics = useHaptics();
const [refreshTrigger, setRefreshTrigger] = createSignal(0);
const [timeEntries, setTimeEntries] = createSignal<TimeEntry[]>([]);
const [loading, setLoading] = createSignal(true); const [loading, setLoading] = createSignal(true);
const [currentRunningEntry, setCurrentRunningEntry] = createSignal<TimeEntry | null>(null); const [timeEntries, setTimeEntries] = createSignal<any[]>([]);
const [currentElapsedSeconds, setCurrentElapsedSeconds] = createSignal(0); 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 let timerInterval: ReturnType<typeof setInterval> | null = null;
const getApi = () => isDemoMode() ? demoTimeEntriesApi : timeEntriesApi;
const loadTimeEntries = async () => { const loadData = async () => {
try { try {
setLoading(true); setLoading(true);
const response = await getApi().getAll(); const entries = await solidtimeApi.getTimeEntries();
setTimeEntries(entries);
// Handle different response formats const active = await solidtimeApi.getActiveTimeEntry();
let entries: TimeEntry[] = []; setCurrentRunningEntry(active);
if (response && response.time_entries) {
entries = response.time_entries; if (active) {
} else if (response && Array.isArray(response)) { const start = new Date(active.start).getTime();
entries = response; const now = Date.now();
setElapsedSeconds(Math.floor((now - start) / 1000));
} else { } else {
console.warn('Unexpected response format:', response); setElapsedSeconds(0);
entries = [];
} }
setTimeEntries(entries); const stats = await solidtimeApi.getTimeEntriesStats();
setStats(stats);
} catch (err) { } catch (err) {
console.error('Failed to load time entries:', err); console.error('Failed to load time entries:', err);
setTimeEntries([]); // Ensure empty array on error
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
// Calculate today's statistics including real-time running timer const startTimer = async () => {
const getTodayStats = () => { try {
const entries = timeEntries() || []; const entry = await solidtimeApi.startTimeEntry({
const today = new Date().toDateString(); description: 'Working on Trackeep',
const todayEntries = entries.filter(entry => });
new Date(entry.start_time).toDateString() === today setCurrentRunningEntry(entry);
); setElapsedSeconds(0);
haptics.impact();
// Start with completed entries loadData();
let totalSeconds = todayEntries.reduce((sum, entry) => } catch (err) {
sum + (entry.duration || 0), 0 console.error('Failed to start timer:', err);
);
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 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 hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60); 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 => { const formatDuration = (start: string, end?: string) => {
return `$${amount.toFixed(2)}`; 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(() => { onMount(() => {
loadTimeEntries(); loadData();
}); });
createEffect(() => { createEffect(() => {
if (refreshTrigger() > 0) { if (currentRunningEntry()) {
loadTimeEntries(); timerInterval = setInterval(() => {
setElapsedSeconds(s => s + 1);
}, 1000);
} else {
if (timerInterval) {
clearInterval(timerInterval);
timerInterval = null;
} }
}
return () => {
if (timerInterval) clearInterval(timerInterval);
};
}); });
return ( return (
<div class="p-6 mt-4 pb-32 max-w-5xl mx-auto space-y-6"> <div class="space-y-6">
{/* Simple loading indicator */} <div class="flex items-center justify-between">
{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> <div>
<Timer <h1 class="text-3xl font-bold tracking-tight">Time Tracking</h1>
onTimeEntryCreated={handleTimeEntryCreated} <p class="text-muted-foreground mt-1">
onTimerUpdate={handleTimerUpdate} Track your time with solidtime.io integration
/> </p>
</div>
</div> </div>
{/* Time Stats - Standardized Design */} <Card class="p-6">
<div class="border rounded-lg p-4"> <div class="flex items-center justify-between">
<h2 class="text-lg font-semibold mb-4">Today's Overview</h2> <div class="flex items-center gap-4">
<div class="grid grid-cols-2 gap-4"> <div class="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center">
<div class="flex items-center gap-3"> <IconClock class="w-6 h-6 text-primary" />
<div class="bg-muted flex items-center justify-center p-2 rounded-lg">
<IconClock class="size-5 text-primary" />
</div> </div>
<div> <div>
<p class="text-2xl font-light">{formatTime(getTodayStats().totalSeconds)}</p> <h2 class="text-lg font-semibold">
<p class="text-sm text-muted-foreground">Total Time Today</p> {currentRunningEntry() ? 'Timer Running' : 'Ready to Track'}
</div> </h2>
</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>
<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"> <p class="text-sm text-muted-foreground">
Billable Today {currentRunningEntry()
{currentRunningEntry() && currentRunningEntry()?.billable && ( ? currentRunningEntry().description || 'No description'
<span class="ml-1 text-green-600 dark:text-green-400"> : 'Start tracking your time'}
Live </p>
</span> </div>
</div>
<div class="text-right">
<div class="text-4xl font-bold tabular-nums">
{formatTime(elapsedSeconds())}
</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>
<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> </p>
</div> </div>
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="bg-muted flex items-center justify-center p-2 rounded-lg"> <Badge variant={entry.billable ? 'default' : 'secondary'}>
<IconActivity class="size-5 text-primary" /> {entry.billable ? 'Billable' : 'Non-Billable'}
</div> </Badge>
<div> <span class="font-mono text-sm">
<p class="text-2xl font-light">{getTodayStats().runningCount}</p> {formatDuration(entry.start, entry.end)}
<p class="text-sm text-muted-foreground">Running Timers</p> </span>
</div> </div>
</div> </div>
))}
</div> </div>
</div> )}
</div> </Card>
{/* Time Entries List */}
<div>
<TimeEntriesList refreshTrigger={refreshTrigger()} />
</div>
</div> </div>
); );
}; };
@@ -2,6 +2,7 @@ import { createSignal, createEffect, Show, For } from 'solid-js';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { ConfirmModal } from '@/components/ui/ConfirmModal';
import { toast } from '@/components/ui/Toast'; import { toast } from '@/components/ui/Toast';
import { CheckCircle, AlertCircle, Shield, Key, Globe, Clock, Users, Settings } from 'lucide-solid'; import { CheckCircle, AlertCircle, Shield, Key, Globe, Clock, Users, Settings } from 'lucide-solid';
import { getApiV1BaseUrl } from '@/lib/api-url'; import { getApiV1BaseUrl } from '@/lib/api-url';
@@ -55,12 +56,19 @@ const BrowserExtensionSettings = () => {
'files:write' 'files:write'
]); ]);
const [activeTab, setActiveTab] = createSignal<'overview' | 'api-keys' | 'extensions' | 'examples'>('api-keys'); 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[] = [ const quickStartGuides: QuickStartGuide[] = [
{ {
title: 'Generate API Key', title: 'Generate API Key',
description: 'Create a secure API key for your browser extension', 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: [ steps: [
'Go to Settings → Browser Extension', 'Go to Settings → Browser Extension',
'Click "Generate New Key"', 'Click "Generate New Key"',
@@ -71,7 +79,7 @@ const BrowserExtensionSettings = () => {
{ {
title: 'Configure Extension', title: 'Configure Extension',
description: 'Set up your browser extension with the API key', 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: [ steps: [
'Install the Trackeep browser extension', 'Install the Trackeep browser extension',
'Open extension options', 'Open extension options',
@@ -83,7 +91,7 @@ const BrowserExtensionSettings = () => {
{ {
title: 'Security Best Practices', title: 'Security Best Practices',
description: 'Keep your API keys secure and monitor usage', 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: [ steps: [
'Use unique names for each key', 'Use unique names for each key',
'Set expiration dates for temporary access', '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) => { const revokeAPIKey = async (keyId: number) => {
if (!confirm('Are you sure you want to revoke this API key? This action cannot be undone.')) { setConfirmTarget({type: 'key', id: keyId});
return; 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 { try {
const response = await fetch(`${apiBaseUrl}/browser-extension/api-keys/${keyId}`, { const response = await fetch(`${apiBaseUrl}/browser-extension/api-keys/${target.id}`, {
method: 'DELETE', method: 'DELETE',
headers: { headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}` '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) { } catch (error) {
toast.error('Error revoking API key'); toast.error('Error revoking API key');
} finally {
setShowConfirmModal(false);
setConfirmTarget(null);
} }
}; };
const revokeExtension = async (extensionId: string) => { const revokeExtension = async (extensionId: string) => {
if (!confirm('Are you sure you want to revoke this extension? This action cannot be undone.')) { setConfirmTarget({type: 'extension', id: extensionId});
return; 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 { try {
const response = await fetch(`${apiBaseUrl}/browser-extension/extensions/${extensionId}`, { const response = await fetch(`${apiBaseUrl}/browser-extension/extensions/${target.id}`, {
method: 'DELETE', method: 'DELETE',
headers: { headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}` '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) { } catch (error) {
toast.error('Error revoking extension'); 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 ( return (
<div class="p-6"> <div class="p-6">
<div class="mb-6"> <div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900 mb-2">Browser Extension Settings</h1> <h1 class="text-2xl font-bold text-foreground 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> <p class="text-muted-foreground">Manage API keys and browser extensions for secure access to your Trackeep account.</p>
</div> </div>
{/* Tab Navigation */} {/* 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"> <nav class="flex space-x-8">
<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() === 'overview' activeTab() === 'overview'
? 'border-blue-500 text-blue-600' ? 'border-primary text-primary'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
}`} }`}
onClick={() => setActiveTab('overview')} onClick={() => setActiveTab('overview')}
> >
<Globe class="w-4 h-4 mr-2" /> <Globe class="w-4 h-4" />
Overview Overview
</button> </button>
<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' activeTab() === 'api-keys'
? 'border-blue-500 text-blue-600' ? 'border-primary text-primary'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
}`} }`}
onClick={() => setActiveTab('api-keys')} onClick={() => setActiveTab('api-keys')}
> >
<Key class="w-4 h-4 mr-2" /> <Key class="w-4 h-4" />
API Keys API Keys
</button> </button>
<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' activeTab() === 'extensions'
? 'border-blue-500 text-blue-600' ? 'border-primary text-primary'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
}`} }`}
onClick={() => setActiveTab('extensions')} onClick={() => setActiveTab('extensions')}
> >
<Users class="w-4 h-4 mr-2" /> <Users class="w-4 h-4" />
Extensions Extensions
</button> </button>
<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' activeTab() === 'examples'
? 'border-blue-500 text-blue-600' ? 'border-primary text-primary'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
}`} }`}
onClick={() => setActiveTab('examples')} onClick={() => setActiveTab('examples')}
> >
<Shield class="w-4 h-4 mr-2" /> <Shield class="w-4 h-4" />
Examples Examples
</button> </button>
</nav> </nav>
@@ -385,18 +415,18 @@ curl -X POST \\\n -H "Authorization: Bearer tk_your_api_key_here" \\\n -H "Con
{guide => ( {guide => (
<Card class="p-6"> <Card class="p-6">
<div class="text-center mb-4"> <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} {guide.icon}
</div> </div>
<h3 class="text-lg font-semibold text-gray-900">{guide.title}</h3> <h3 class="text-lg font-semibold text-foreground">{guide.title}</h3>
<p class="text-gray-600 text-sm mb-4">{guide.description}</p> <p class="text-muted-foreground text-sm mb-4">{guide.description}</p>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<For each={guide.steps}> <For each={guide.steps}>
{step => ( {step => (
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<CheckCircle class="w-4 h-4 text-green-500 flex-shrink-0" /> <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> </div>
)} )}
</For> </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"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card class="p-6"> <Card class="p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4 flex items-center"> <h3 class="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<AlertCircle class="w-5 h-5 text-orange-500 mr-2" /> <AlertCircle class="w-5 h-5 text-orange-500" />
Security Status Security Status
</h3> </h3>
<div class="space-y-3"> <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 class="w-3 h-3 bg-green-500 rounded-full"></div>
<div> <div>
<div class="font-medium text-green-700">API Keys Secure</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> </div>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<div class="w-3 h-3 bg-green-500 rounded-full"></div> <div class="w-3 h-3 bg-green-500 rounded-full"></div>
<div> <div>
<div class="font-medium text-green-700">Extensions Registered</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> </div>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<div class="w-3 h-3 bg-yellow-500 rounded-full"></div> <div class="w-3 h-3 bg-yellow-500 rounded-full"></div>
<div> <div>
<div class="font-medium text-yellow-700">Quick Setup Available</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> </div>
</div> </div>
</Card> </Card>
<Card class="p-6"> <Card class="p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4 flex items-center"> <h3 class="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<Clock class="w-5 h-5 text-blue-500 mr-2" /> <Clock class="w-5 h-5 text-primary" />
Recent Activity Recent Activity
</h3> </h3>
<div class="space-y-2"> <div class="space-y-2">
<div class="text-sm text-gray-600"> <div class="text-sm text-muted-foreground">
<div class="font-medium text-gray-900">Last API key created:</div> <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>{apiKeys().length > 0 ? new Date(apiKeys()[0].created_at).toLocaleDateString() : 'No keys created yet'}</div>
</div> </div>
<div class="text-sm text-gray-600"> <div class="text-sm text-muted-foreground">
<div class="font-medium text-gray-900">Total API keys:</div> <div class="font-medium text-foreground">Total API keys:</div>
<div>{apiKeys().length} active, {apiKeys().filter(k => !k.is_active).length} revoked</div> <div>{apiKeys().length} active, {apiKeys().filter(k => !k.is_active).length} revoked</div>
</div> </div>
<div class="text-sm text-gray-600"> <div class="text-sm text-muted-foreground">
<div class="font-medium text-gray-900">Extensions active:</div> <div class="font-medium text-foreground">Extensions active:</div>
<div>{extensions().length} connected</div> <div>{extensions().length} connected</div>
</div> </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'}> <Show when={activeTab() === 'api-keys'}>
<Card class="mb-6"> <Card class="mb-6">
<div class="flex justify-between items-center mb-4"> <div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-semibold">API Keys</h2> <h2 class="text-xl font-semibold text-foreground">API Keys</h2>
<Button onClick={() => setShowCreateKey(true)} class="btn-primary"> <Button onClick={() => setShowCreateKey(true)}>
Generate New Key Generate New Key
</Button> </Button>
</div> </div>
<Show when={showCreateKey()}> <Show when={showCreateKey()}>
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4"> <div class="bg-primary/5 border border-primary/20 rounded-lg p-4 mb-4">
<h3 class="text-lg font-medium mb-4">Create New API Key</h3> <h3 class="text-lg font-medium text-foreground mb-4">Create New API Key</h3>
<div class="mb-4"> <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 <Input
value={newKeyName()} value={newKeyName()}
onInput={(e: InputEvent) => setNewKeyName((e.target as HTMLInputElement).value)} 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>
<div class="mb-4"> <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"> <div class="space-y-2">
<For each={availablePermissions}> <For each={availablePermissions}>
{permission => ( {permission => (
@@ -494,11 +524,11 @@ curl -X POST \\\n -H "Authorization: Bearer tk_your_api_key_here" \\\n -H "Con
type="checkbox" type="checkbox"
checked={newKeyPermissions().includes(permission.id)} checked={newKeyPermissions().includes(permission.id)}
onChange={() => togglePermission(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> <div>
<span class="font-medium">{permission.label}</span> <span class="font-medium text-foreground">{permission.label}</span>
<span class="text-sm text-gray-500">{permission.description}</span> <span class="text-sm text-muted-foreground">{permission.description}</span>
</div> </div>
</label> </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"> <div class="flex space-x-2">
<Button <Button
variant="outline"
onClick={() => setShowCreateKey(false)} onClick={() => setShowCreateKey(false)}
class="btn-secondary"
> >
Cancel Cancel
</Button> </Button>
<Button <Button
onClick={createAPIKey} onClick={createAPIKey}
disabled={loading()} disabled={loading()}
class="btn-primary"
> >
{loading() ? 'Creating...' : 'Create Key'} {loading() ? 'Creating...' : 'Create Key'}
</Button> </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"> <div class="space-y-3">
<For each={apiKeys()}> <For each={apiKeys()}>
{key => ( {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 justify-between items-start">
<div class="flex-1"> <div class="flex-1">
<h3 class="text-lg font-medium">{key.name}</h3> <h3 class="text-lg font-medium text-foreground">{key.name}</h3>
<div class="text-sm text-gray-500 mb-2"> <div class="text-sm text-muted-foreground mb-2">
Created: {new Date(key.created_at).toLocaleDateString()} Created: {new Date(key.created_at).toLocaleDateString()}
</div> </div>
<div class="flex flex-wrap gap-1"> <div class="flex flex-wrap gap-1">
<For each={key.permissions}> <For each={key.permissions}>
{permission => ( {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} {permission}
</span> </span>
)} )}
</For> </For>
</div> </div>
{key.expires_at && ( {key.expires_at && (
<div class="text-sm text-orange-600"> <div class="text-sm text-orange-500">
Expires: {new Date(key.expires_at).toLocaleDateString()} Expires: {new Date(key.expires_at).toLocaleDateString()}
</div> </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'} {key.is_active ? 'Active' : 'Revoked'}
</span> </span>
<Button <Button
variant="outline"
onClick={() => revokeAPIKey(key.id)} onClick={() => revokeAPIKey(key.id)}
class="btn-secondary btn-sm"
disabled={!key.is_active} disabled={!key.is_active}
> >
Revoke Revoke
@@ -572,27 +601,28 @@ curl -X POST \\\n -H "Authorization: Bearer tk_your_api_key_here" \\\n -H "Con
</Show> </Show>
{/* Browser Extensions Section */} {/* Browser Extensions Section */}
<Show when={activeTab() === 'extensions'}>
<Card> <Card>
<div class="mb-4"> <div class="mb-4">
<h2 class="text-xl font-semibold">Registered Extensions</h2> <h2 class="text-xl font-semibold text-foreground">Registered Extensions</h2>
<p class="text-sm text-gray-600">Manage browser extensions that have access to your account.</p> <p class="text-sm text-muted-foreground">Manage browser extensions that have access to your account.</p>
</div> </div>
<div class="space-y-3"> <div class="space-y-3">
<For each={extensions()}> <For each={extensions()}>
{extension => ( {extension => (
<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 justify-between items-start">
<div class="flex-1"> <div class="flex-1">
<h3 class="text-lg font-medium">{extension.name}</h3> <h3 class="text-lg font-medium text-foreground">{extension.name}</h3>
<div class="text-sm text-gray-500 mb-2"> <div class="text-sm text-muted-foreground mb-2">
Extension ID: {extension.extension_id} Extension ID: {extension.extension_id}
</div> </div>
<div class="text-sm text-gray-500 mb-2"> <div class="text-sm text-muted-foreground mb-2">
Registered: {new Date(extension.created_at).toLocaleDateString()} Registered: {new Date(extension.created_at).toLocaleDateString()}
</div> </div>
{extension.last_seen && ( {extension.last_seen && (
<div class="text-sm text-gray-500"> <div class="text-sm text-muted-foreground">
Last seen: {new Date(extension.last_seen).toLocaleDateString()} Last seen: {new Date(extension.last_seen).toLocaleDateString()}
</div> </div>
)} )}
@@ -604,8 +634,8 @@ curl -X POST \\\n -H "Authorization: Bearer tk_your_api_key_here" \\\n -H "Con
{extension.is_active ? 'Active' : 'Revoked'} {extension.is_active ? 'Active' : 'Revoked'}
</span> </span>
<Button <Button
variant="outline"
onClick={() => revokeExtension(extension.extension_id)} onClick={() => revokeExtension(extension.extension_id)}
class="btn-secondary btn-sm"
disabled={!extension.is_active} disabled={!extension.is_active}
> >
Revoke Revoke
@@ -617,6 +647,7 @@ curl -X POST \\\n -H "Authorization: Bearer tk_your_api_key_here" \\\n -H "Con
</For> </For>
</div> </div>
</Card> </Card>
</Show>
{/* Examples Tab */} {/* Examples Tab */}
<Show when={activeTab() === 'examples'}> <Show when={activeTab() === 'examples'}>
@@ -625,34 +656,33 @@ curl -X POST \\\n -H "Authorization: Bearer tk_your_api_key_here" \\\n -H "Con
{example => ( {example => (
<Card class="p-6"> <Card class="p-6">
<div class="mb-4"> <div class="mb-4">
<h3 class="text-lg font-semibold text-gray-900 mb-2 flex items-center"> <h3 class="text-lg font-semibold text-foreground mb-2 flex items-center gap-3">
<div class="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center mr-3"> <div class="w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center">
<span class="text-blue-600 font-mono text-xs font-bold">{example.language.toUpperCase()}</span> <span class="text-primary font-mono text-xs font-bold">{example.language.toUpperCase()}</span>
</div> </div>
{example.title} {example.title}
</h3> </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>
<div class="bg-gray-900 rounded-lg p-4 overflow-x-auto"> <div class="bg-muted rounded-lg p-4 overflow-x-auto">
<pre class="text-green-400 text-sm"> <pre class="text-foreground text-sm">
<code>{example.code}</code> <code>{example.code}</code>
</pre> </pre>
</div> </div>
<div class="flex justify-between items-center mt-4"> <div class="flex justify-between items-center mt-4">
<Button <Button
variant="outline"
onClick={() => { onClick={() => {
navigator.clipboard.writeText(example.code); navigator.clipboard.writeText(example.code);
toast.success('Code copied to clipboard!'); toast.success('Code copied to clipboard!');
}} }}
class="btn-secondary"
> >
Copy Code Copy Code
</Button> </Button>
<Button <Button
onClick={() => window.open(`https://your-trackeep.com/api/v1/browser-extension/validate`, '_blank')} onClick={() => window.open(`${apiBaseUrl}/browser-extension/validate`, '_blank')}
class="btn-primary"
> >
Test API Test API
</Button> </Button>
@@ -662,6 +692,19 @@ curl -X POST \\\n -H "Authorization: Bearer tk_your_api_key_here" \\\n -H "Con
</For> </For>
</div> </div>
</Show> </Show>
<ConfirmModal
isOpen={showConfirmModal()}
onClose={() => {
setShowConfirmModal(false);
setConfirmTarget(null);
}}
onConfirm={() => confirmModalConfig().onConfirm()}
title={confirmModalConfig().title}
message={confirmModalConfig().message}
confirmText="Revoke"
type="danger"
/>
</div> </div>
); );
}; };
File diff suppressed because it is too large Load Diff