mirror of
https://github.com/Dvorinka/Trackeep.git
synced 2026-07-29 05:53:50 +00:00
Remove AI/chat/search features, add solidtime integration and workspace setup
This commit is contained in:
@@ -1,553 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/trackeep/backend/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AIRecommendationService provides AI-powered recommendations
|
||||
type AIRecommendationService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewAIRecommendationService creates a new AI recommendation service
|
||||
func NewAIRecommendationService(db *gorm.DB) *AIRecommendationService {
|
||||
return &AIRecommendationService{db: db}
|
||||
}
|
||||
|
||||
// RecommendationRequest represents a request for recommendations
|
||||
type RecommendationRequest struct {
|
||||
UserID uint `json:"user_id"`
|
||||
RecommendationType string `json:"recommendation_type"` // content, task, learning, connection
|
||||
Limit int `json:"limit"` // Max recommendations to return
|
||||
MinConfidence float64 `json:"min_confidence"` // Minimum confidence threshold
|
||||
IncludeDismissed bool `json:"include_dismissed"` // Include previously dismissed items
|
||||
Context string `json:"context"` // Current user context
|
||||
}
|
||||
|
||||
// RecommendationScore represents a scored recommendation
|
||||
type RecommendationScore struct {
|
||||
Recommendation models.AIRecommendation
|
||||
Score float64
|
||||
Reason string
|
||||
}
|
||||
|
||||
// GetRecommendations generates personalized recommendations for a user
|
||||
func (s *AIRecommendationService) GetRecommendations(req RecommendationRequest) ([]models.AIRecommendation, error) {
|
||||
// Get user preferences
|
||||
var prefs models.UserPreference
|
||||
if err := s.db.Where("user_id = ?", req.UserID).First(&prefs).Error; err != nil {
|
||||
// Create default preferences if not found
|
||||
prefs = models.UserPreference{
|
||||
UserID: req.UserID,
|
||||
EnableRecommendations: true,
|
||||
MinConfidenceThreshold: 0.6,
|
||||
MaxRecommendationsPerDay: 5,
|
||||
MaxAgeHours: 168,
|
||||
}
|
||||
s.db.Create(&prefs)
|
||||
}
|
||||
|
||||
if !prefs.EnableRecommendations {
|
||||
return []models.AIRecommendation{}, nil
|
||||
}
|
||||
|
||||
// Check daily limit
|
||||
today := time.Now().Format("2006-01-02")
|
||||
var todayCount int64
|
||||
s.db.Model(&models.AIRecommendation{}).
|
||||
Where("user_id = ? AND DATE(created_at) = ?", req.UserID, today).
|
||||
Count(&todayCount)
|
||||
|
||||
if int(todayCount) >= prefs.MaxRecommendationsPerDay {
|
||||
return []models.AIRecommendation{}, nil
|
||||
}
|
||||
|
||||
// Generate recommendations based on type
|
||||
var scoredRecommendations []RecommendationScore
|
||||
|
||||
switch req.RecommendationType {
|
||||
case "content":
|
||||
scoredRecommendations = s.generateContentRecommendations(req.UserID, &prefs)
|
||||
case "task":
|
||||
scoredRecommendations = s.generateTaskRecommendations(req.UserID, &prefs)
|
||||
case "learning":
|
||||
scoredRecommendations = s.generateLearningRecommendations(req.UserID, &prefs)
|
||||
case "connection":
|
||||
scoredRecommendations = s.generateConnectionRecommendations(req.UserID, &prefs)
|
||||
default:
|
||||
// Generate mixed recommendations
|
||||
scoredRecommendations = s.generateMixedRecommendations(req.UserID, &prefs)
|
||||
}
|
||||
|
||||
// Filter by confidence and dismissed status
|
||||
minConf := req.MinConfidence
|
||||
if minConf == 0 {
|
||||
minConf = prefs.MinConfidenceThreshold
|
||||
}
|
||||
|
||||
var filtered []RecommendationScore
|
||||
for _, rec := range scoredRecommendations {
|
||||
if rec.Score >= minConf && (req.IncludeDismissed || !rec.Recommendation.Dismissed) {
|
||||
// Check if not expired
|
||||
if rec.Recommendation.ExpiresAt == nil || rec.Recommendation.ExpiresAt.After(time.Now()) {
|
||||
filtered = append(filtered, rec)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by score
|
||||
sort.Slice(filtered, func(i, j int) bool {
|
||||
return filtered[i].Score > filtered[j].Score
|
||||
})
|
||||
|
||||
// Apply limit
|
||||
limit := req.Limit
|
||||
if limit == 0 {
|
||||
limit = 5
|
||||
}
|
||||
if limit > len(filtered) {
|
||||
limit = len(filtered)
|
||||
}
|
||||
|
||||
// Save recommendations to database
|
||||
var recommendations []models.AIRecommendation
|
||||
for i := 0; i < limit; i++ {
|
||||
rec := filtered[i].Recommendation
|
||||
rec.Confidence = filtered[i].Score
|
||||
|
||||
// Check if already exists
|
||||
var existing models.AIRecommendation
|
||||
err := s.db.Where("user_id = ? AND content_type = ? AND content_id = ?",
|
||||
rec.UserID, rec.ContentType, rec.ContentID).First(&existing).Error
|
||||
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
// Create new recommendation
|
||||
if err := s.db.Create(&rec).Error; err != nil {
|
||||
continue
|
||||
}
|
||||
recommendations = append(recommendations, rec)
|
||||
} else {
|
||||
// Update existing recommendation
|
||||
existing.Confidence = rec.Confidence
|
||||
existing.Reasoning = filtered[i].Reason
|
||||
existing.UpdatedAt = time.Now()
|
||||
s.db.Save(&existing)
|
||||
recommendations = append(recommendations, existing)
|
||||
}
|
||||
}
|
||||
|
||||
return recommendations, nil
|
||||
}
|
||||
|
||||
// generateContentRecommendations generates content-based recommendations
|
||||
func (s *AIRecommendationService) generateContentRecommendations(userID uint, prefs *models.UserPreference) []RecommendationScore {
|
||||
var recommendations []RecommendationScore
|
||||
|
||||
// Get user's recent activity and interests
|
||||
userTags := s.getUserInterests(userID)
|
||||
userCategories := s.getUserCategories(userID)
|
||||
|
||||
// Find similar content from other users
|
||||
var similarContent []struct {
|
||||
ContentType string
|
||||
ContentID uint
|
||||
Title string
|
||||
URL string
|
||||
Preview string
|
||||
Author string
|
||||
Tags string
|
||||
Score float64
|
||||
}
|
||||
|
||||
// Query for popular content among similar users
|
||||
query := `
|
||||
SELECT
|
||||
b.content_type,
|
||||
b.content_id,
|
||||
b.title,
|
||||
b.url,
|
||||
SUBSTRING(b.content, 1, 200) as preview,
|
||||
u.full_name as author,
|
||||
b.tags,
|
||||
COUNT(*) as interaction_count
|
||||
FROM bookmarks b
|
||||
INNER JOIN users u ON b.user_id = u.id
|
||||
WHERE b.user_id != ?
|
||||
AND b.created_at > ?
|
||||
AND (b.tags ?| array[?] OR b.content_type = ANY(?))
|
||||
GROUP BY b.content_type, b.content_id, b.title, b.url, b.content, u.full_name, b.tags
|
||||
HAVING COUNT(*) > 1
|
||||
ORDER BY interaction_count DESC
|
||||
LIMIT 20
|
||||
`
|
||||
|
||||
// This is a simplified version - in practice you'd use more sophisticated similarity algorithms
|
||||
s.db.Raw(query, userID, time.Now().AddDate(0, -3, 0), userTags, prefs.PreferredContentTypes).Scan(&similarContent)
|
||||
|
||||
for _, content := range similarContent {
|
||||
score := s.calculateContentScore(content, userTags, userCategories, prefs)
|
||||
|
||||
expiresAt := time.Now().Add(time.Hour * 24 * 7)
|
||||
|
||||
recommendation := models.AIRecommendation{
|
||||
UserID: userID,
|
||||
RecommendationType: "content",
|
||||
ContentType: content.ContentType,
|
||||
ContentID: &content.ContentID,
|
||||
Title: content.Title,
|
||||
Description: fmt.Sprintf("Recommended based on your interests in %s", strings.Join(userTags, ", ")),
|
||||
ContentTitle: content.Title,
|
||||
ContentURL: content.URL,
|
||||
ContentPreview: content.Preview,
|
||||
AuthorName: content.Author,
|
||||
Tags: content.Tags,
|
||||
Priority: s.getPriorityFromScore(score),
|
||||
ExpiresAt: &expiresAt,
|
||||
SourceModel: "collaborative_filtering_v1",
|
||||
}
|
||||
|
||||
recommendations = append(recommendations, RecommendationScore{
|
||||
Recommendation: recommendation,
|
||||
Score: score,
|
||||
Reason: fmt.Sprintf("Similar users with interests in %s also liked this", strings.Join(userTags[:2], ", ")),
|
||||
})
|
||||
}
|
||||
|
||||
return recommendations
|
||||
}
|
||||
|
||||
// generateTaskRecommendations generates task-based recommendations
|
||||
func (s *AIRecommendationService) generateTaskRecommendations(userID uint, prefs *models.UserPreference) []RecommendationScore {
|
||||
var recommendations []RecommendationScore
|
||||
|
||||
// Get user's task patterns and upcoming deadlines
|
||||
var userTasks []models.Task
|
||||
s.db.Where("user_id = ? AND status != 'completed'", userID).Find(&userTasks)
|
||||
|
||||
// Get calendar events for context
|
||||
var upcomingEvents []models.CalendarEvent
|
||||
s.db.Where("user_id = ? AND start_time BETWEEN ? AND ?",
|
||||
userID, time.Now(), time.Now().AddDate(0, 0, 7)).Find(&upcomingEvents)
|
||||
|
||||
// Analyze patterns and suggest tasks
|
||||
for _, task := range userTasks {
|
||||
if string(task.Priority) == "high" && task.DueDate != nil && task.DueDate.Before(time.Now().AddDate(0, 0, 3)) {
|
||||
// High priority task due soon
|
||||
score := 0.9
|
||||
|
||||
// Convert tags to string
|
||||
var tagStr string
|
||||
for i, tag := range task.Tags {
|
||||
if i > 0 {
|
||||
tagStr += ","
|
||||
}
|
||||
tagStr += tag.Name
|
||||
}
|
||||
|
||||
expiresAt := task.DueDate
|
||||
|
||||
recommendation := models.AIRecommendation{
|
||||
UserID: userID,
|
||||
RecommendationType: "task",
|
||||
ContentType: "task",
|
||||
ContentID: &task.ID,
|
||||
Title: fmt.Sprintf("Focus on: %s", task.Title),
|
||||
Description: fmt.Sprintf("This high-priority task is due on %s", task.DueDate.Format("Jan 2")),
|
||||
ContentTitle: task.Title,
|
||||
ContentPreview: task.Description,
|
||||
Tags: tagStr,
|
||||
Priority: "high",
|
||||
Confidence: score,
|
||||
ExpiresAt: expiresAt,
|
||||
SourceModel: "deadline_priority_v1",
|
||||
}
|
||||
|
||||
recommendations = append(recommendations, RecommendationScore{
|
||||
Recommendation: recommendation,
|
||||
Score: score,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return recommendations
|
||||
}
|
||||
|
||||
// generateLearningRecommendations generates learning-based recommendations
|
||||
func (s *AIRecommendationService) generateLearningRecommendations(userID uint, prefs *models.UserPreference) []RecommendationScore {
|
||||
var recommendations []RecommendationScore
|
||||
|
||||
// Get user's learning progress and interests
|
||||
var enrollments []models.Enrollment
|
||||
s.db.Preload("Course").Preload("LearningPath").Where("user_id = ?", userID).Find(&enrollments)
|
||||
|
||||
var completedCategories []string
|
||||
var inProgressCategories []string
|
||||
for _, enrollment := range enrollments {
|
||||
if enrollment.Progress >= 100 {
|
||||
if enrollment.CourseID != nil && enrollment.Course != nil {
|
||||
completedCategories = append(completedCategories, enrollment.Course.Category)
|
||||
} else if enrollment.LearningPathID != 0 {
|
||||
completedCategories = append(completedCategories, enrollment.LearningPath.Category)
|
||||
}
|
||||
} else {
|
||||
if enrollment.CourseID != nil && enrollment.Course != nil {
|
||||
inProgressCategories = append(inProgressCategories, enrollment.Course.Category)
|
||||
} else if enrollment.LearningPathID != 0 {
|
||||
inProgressCategories = append(inProgressCategories, enrollment.LearningPath.Category)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recommend next courses based on completed ones
|
||||
for _, category := range completedCategories {
|
||||
var nextCourses []models.Course
|
||||
s.db.Where("category = ? AND level != ?", category, "beginner").Limit(3).Find(&nextCourses)
|
||||
|
||||
for _, course := range nextCourses {
|
||||
score := 0.8
|
||||
|
||||
// Convert topics to tags string
|
||||
var tagsStr string
|
||||
for i, topic := range course.Topics {
|
||||
if i > 0 {
|
||||
tagsStr += ","
|
||||
}
|
||||
tagsStr += topic
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Add(time.Hour * 24 * 14)
|
||||
|
||||
recommendation := models.AIRecommendation{
|
||||
UserID: userID,
|
||||
RecommendationType: "learning",
|
||||
ContentType: "course",
|
||||
ContentID: &course.ID,
|
||||
Title: fmt.Sprintf("Continue learning: %s", course.Title),
|
||||
Description: fmt.Sprintf("Based on your completion of %s courses", category),
|
||||
ContentTitle: course.Title,
|
||||
ContentPreview: course.Description,
|
||||
Tags: tagsStr,
|
||||
Priority: "medium",
|
||||
Confidence: score,
|
||||
ExpiresAt: &expiresAt,
|
||||
SourceModel: "learning_path_v1",
|
||||
}
|
||||
|
||||
recommendations = append(recommendations, RecommendationScore{
|
||||
Recommendation: recommendation,
|
||||
Score: score,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return recommendations
|
||||
}
|
||||
|
||||
// generateConnectionRecommendations generates user connection recommendations
|
||||
func (s *AIRecommendationService) generateConnectionRecommendations(userID uint, prefs *models.UserPreference) []RecommendationScore {
|
||||
var recommendations []RecommendationScore
|
||||
|
||||
if !prefs.ConnectionRecommendations {
|
||||
return recommendations
|
||||
}
|
||||
|
||||
// Get user's skills and interests
|
||||
var user models.User
|
||||
s.db.Preload("Skills").Where("id = ?", userID).First(&user)
|
||||
|
||||
// Find similar users
|
||||
var similarUsers []models.User
|
||||
s.db.Where("id != ? AND (skills ?| array[?] OR location = ?)",
|
||||
userID, s.getSkillNames(user.Skills), user.Location).Limit(5).Find(&similarUsers)
|
||||
|
||||
for _, similarUser := range similarUsers {
|
||||
score := s.calculateUserSimilarity(&user, &similarUser)
|
||||
|
||||
if score > 0.6 {
|
||||
expiresAt := time.Now().Add(time.Hour * 24 * 30)
|
||||
|
||||
recommendation := models.AIRecommendation{
|
||||
UserID: userID,
|
||||
RecommendationType: "connection",
|
||||
ContentType: "user",
|
||||
ContentID: &similarUser.ID,
|
||||
Title: fmt.Sprintf("Connect with %s", similarUser.FullName),
|
||||
Description: fmt.Sprintf("Similar interests in %s", s.getSharedInterests(&user, &similarUser)),
|
||||
ContentTitle: similarUser.FullName,
|
||||
ContentPreview: similarUser.Bio,
|
||||
AuthorName: similarUser.JobTitle,
|
||||
Priority: "low",
|
||||
Confidence: score,
|
||||
ExpiresAt: &expiresAt,
|
||||
SourceModel: "user_similarity_v1",
|
||||
}
|
||||
|
||||
recommendations = append(recommendations, RecommendationScore{
|
||||
Recommendation: recommendation,
|
||||
Score: score,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return recommendations
|
||||
}
|
||||
|
||||
// generateMixedRecommendations generates a mix of all recommendation types
|
||||
func (s *AIRecommendationService) generateMixedRecommendations(userID uint, prefs *models.UserPreference) []RecommendationScore {
|
||||
var allRecommendations []RecommendationScore
|
||||
|
||||
// Get recommendations from all types
|
||||
contentRecs := s.generateContentRecommendations(userID, prefs)
|
||||
taskRecs := s.generateTaskRecommendations(userID, prefs)
|
||||
learningRecs := s.generateLearningRecommendations(userID, prefs)
|
||||
connectionRecs := s.generateConnectionRecommendations(userID, prefs)
|
||||
|
||||
allRecommendations = append(allRecommendations, contentRecs...)
|
||||
allRecommendations = append(allRecommendations, taskRecs...)
|
||||
allRecommendations = append(allRecommendations, learningRecs...)
|
||||
allRecommendations = append(allRecommendations, connectionRecs...)
|
||||
|
||||
// Ensure diverse mix by limiting each type
|
||||
maxPerType := 2
|
||||
typeCounts := make(map[string]int)
|
||||
var mixedRecs []RecommendationScore
|
||||
|
||||
for _, rec := range allRecommendations {
|
||||
if typeCounts[rec.Recommendation.ContentType] < maxPerType {
|
||||
mixedRecs = append(mixedRecs, rec)
|
||||
typeCounts[rec.Recommendation.ContentType]++
|
||||
}
|
||||
}
|
||||
|
||||
return mixedRecs
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func (s *AIRecommendationService) getUserInterests(userID uint) []string {
|
||||
var tags []string
|
||||
s.db.Model(&models.Bookmark{}).
|
||||
Select("DISTINCT unnest(string_to_array(tags, ',')) as tag").
|
||||
Where("user_id = ?", userID).
|
||||
Pluck("tag", &tags)
|
||||
return tags
|
||||
}
|
||||
|
||||
func (s *AIRecommendationService) getUserCategories(userID uint) []string {
|
||||
var categories []string
|
||||
s.db.Model(&models.Course{}).
|
||||
Select("DISTINCT category").
|
||||
Joins("JOIN enrollments ON courses.id = enrollments.course_id").
|
||||
Where("enrollments.user_id = ?", userID).
|
||||
Pluck("category", &categories)
|
||||
return categories
|
||||
}
|
||||
|
||||
func (s *AIRecommendationService) calculateContentScore(content interface{}, userTags, userCategories []string, prefs *models.UserPreference) float64 {
|
||||
// Simplified scoring algorithm
|
||||
score := 0.5 // Base score
|
||||
|
||||
// Add points for tag matches
|
||||
// In practice, this would be more sophisticated
|
||||
score += float64(len(userTags)) * 0.1
|
||||
|
||||
// Ensure score is within bounds
|
||||
if score > 1.0 {
|
||||
score = 1.0
|
||||
}
|
||||
if score < 0.0 {
|
||||
score = 0.0
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
func (s *AIRecommendationService) getPriorityFromScore(score float64) string {
|
||||
if score >= 0.8 {
|
||||
return "high"
|
||||
} else if score >= 0.6 {
|
||||
return "medium"
|
||||
}
|
||||
return "low"
|
||||
}
|
||||
|
||||
func (s *AIRecommendationService) getSkillNames(skills []models.Skill) []string {
|
||||
var names []string
|
||||
for _, skill := range skills {
|
||||
names = append(names, skill.Name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func (s *AIRecommendationService) calculateUserSimilarity(user1, user2 *models.User) float64 {
|
||||
// Simplified similarity calculation
|
||||
score := 0.0
|
||||
|
||||
// Location match
|
||||
if user1.Location == user2.Location && user1.Location != "" {
|
||||
score += 0.3
|
||||
}
|
||||
|
||||
// Skills overlap (simplified)
|
||||
if len(user1.Skills) > 0 && len(user2.Skills) > 0 {
|
||||
score += 0.4
|
||||
}
|
||||
|
||||
// Random factor for demo
|
||||
score += 0.2
|
||||
|
||||
if score > 1.0 {
|
||||
score = 1.0
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
func (s *AIRecommendationService) getSharedInterests(user1, user2 *models.User) string {
|
||||
// Simplified - in practice would analyze actual shared interests
|
||||
interests := []string{"technology", "productivity", "learning"}
|
||||
if len(interests) > 2 {
|
||||
return strings.Join(interests[:2], ", ")
|
||||
}
|
||||
return strings.Join(interests, ", ")
|
||||
}
|
||||
|
||||
// RecordInteraction records user interaction with recommendations
|
||||
func (s *AIRecommendationService) RecordInteraction(userID, recommendationID uint, interactionType, context string) error {
|
||||
interaction := models.RecommendationInteraction{
|
||||
UserID: userID,
|
||||
RecommendationID: recommendationID,
|
||||
InteractionType: interactionType,
|
||||
Context: context,
|
||||
SessionID: fmt.Sprintf("session_%d_%d", userID, time.Now().Unix()),
|
||||
DeviceType: "web", // Would be detected from request
|
||||
}
|
||||
|
||||
// Update recommendation based on interaction
|
||||
var recommendation models.AIRecommendation
|
||||
if err := s.db.First(&recommendation, recommendationID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch interactionType {
|
||||
case "click":
|
||||
recommendation.Clicked = true
|
||||
now := time.Now()
|
||||
recommendation.ClickedAt = &now
|
||||
case "dismiss":
|
||||
recommendation.Dismissed = true
|
||||
now := time.Now()
|
||||
recommendation.DismissedAt = &now
|
||||
case "feedback":
|
||||
// Additional feedback handling would go here
|
||||
}
|
||||
|
||||
s.db.Save(&recommendation)
|
||||
return s.db.Create(&interaction).Error
|
||||
}
|
||||
@@ -1,532 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AIProvider represents different AI providers
|
||||
type AIProvider string
|
||||
|
||||
const (
|
||||
ProviderMistral AIProvider = "mistral"
|
||||
ProviderLongCat AIProvider = "longcat"
|
||||
ProviderGrok AIProvider = "grok"
|
||||
ProviderDeepSeek AIProvider = "deepseek"
|
||||
ProviderOllama AIProvider = "ollama"
|
||||
ProviderOpenRouter AIProvider = "openrouter"
|
||||
)
|
||||
|
||||
// AIRequest represents a generic AI request
|
||||
type AIRequest struct {
|
||||
Messages []Message `json:"messages"`
|
||||
Model string `json:"model"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
ModelType string `json:"model_type,omitempty"` // "standard", "thinking", "upgraded_thinking"
|
||||
}
|
||||
|
||||
// Message represents a chat message
|
||||
type Message struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||
}
|
||||
|
||||
// AIResponse represents a generic AI response
|
||||
type AIResponse struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created"`
|
||||
Model string `json:"model"`
|
||||
Choices []Choice `json:"choices"`
|
||||
Usage Usage `json:"usage"`
|
||||
}
|
||||
|
||||
// Choice represents a choice in AI response
|
||||
type Choice struct {
|
||||
Index int `json:"index"`
|
||||
Message Message `json:"message"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
}
|
||||
|
||||
// Usage represents token usage
|
||||
type Usage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
}
|
||||
|
||||
// AIService handles multiple AI providers
|
||||
type AIService struct {
|
||||
provider AIProvider
|
||||
}
|
||||
|
||||
// NewAIService creates a new AI service with the specified provider
|
||||
func NewAIService(provider AIProvider) *AIService {
|
||||
return &AIService{provider: provider}
|
||||
}
|
||||
|
||||
// GetAvailableProviders returns available AI providers
|
||||
func GetAvailableProviders() []AIProvider {
|
||||
// Return all known providers so the frontend can show them in settings
|
||||
// regardless of current environment configuration. Environment flags
|
||||
// and API keys still control whether requests actually succeed.
|
||||
return []AIProvider{
|
||||
ProviderMistral,
|
||||
ProviderLongCat,
|
||||
ProviderGrok,
|
||||
ProviderDeepSeek,
|
||||
ProviderOllama,
|
||||
ProviderOpenRouter,
|
||||
}
|
||||
}
|
||||
|
||||
// ChatCompletion sends a chat completion request to the configured provider
|
||||
func (s *AIService) ChatCompletion(req AIRequest) (*AIResponse, error) {
|
||||
switch s.provider {
|
||||
case ProviderMistral:
|
||||
return s.callMistral(req)
|
||||
case ProviderLongCat:
|
||||
return s.callLongCat(req)
|
||||
case ProviderGrok:
|
||||
return s.callGrok(req)
|
||||
case ProviderDeepSeek:
|
||||
return s.callDeepSeek(req)
|
||||
case ProviderOllama:
|
||||
return s.callOllama(req)
|
||||
case ProviderOpenRouter:
|
||||
return s.callOpenRouter(req)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported AI provider: %s", s.provider)
|
||||
}
|
||||
}
|
||||
|
||||
// ChatCompletionWithThinking sends a chat completion request with thinking model
|
||||
func (s *AIService) ChatCompletionWithThinking(req AIRequest) (*AIResponse, error) {
|
||||
// Override model with thinking model
|
||||
thinkingModel := s.getThinkingModel()
|
||||
if thinkingModel != "" {
|
||||
req.Model = thinkingModel
|
||||
}
|
||||
|
||||
return s.ChatCompletion(req)
|
||||
}
|
||||
|
||||
// ChatCompletionWithUpgradedThinking sends a chat completion request with upgraded thinking model (LongCat only)
|
||||
func (s *AIService) ChatCompletionWithUpgradedThinking(req AIRequest) (*AIResponse, error) {
|
||||
if s.provider != ProviderLongCat {
|
||||
return nil, fmt.Errorf("upgraded thinking model only available for LongCat provider")
|
||||
}
|
||||
|
||||
// Override model with upgraded thinking model
|
||||
upgradedModel := os.Getenv("LONGCAT_MODEL_THINKING_UPGRADED")
|
||||
if upgradedModel != "" {
|
||||
req.Model = upgradedModel
|
||||
}
|
||||
|
||||
return s.ChatCompletion(req)
|
||||
}
|
||||
|
||||
// ParseThinkingResponse extracts the actual content from thinking model responses
|
||||
func ParseThinkingResponse(resp *AIResponse, provider AIProvider, modelType string) string {
|
||||
if provider == ProviderLongCat {
|
||||
// Handle LongCat thinking models
|
||||
if resp.Choices[0].Message.Content != "" {
|
||||
content := resp.Choices[0].Message.Content
|
||||
|
||||
// For LongCat-Flash-Thinking, remove thinking tags
|
||||
if strings.Contains(content, "<longcat_think>") {
|
||||
// Extract content after thinking tags
|
||||
parts := strings.Split(content, "</longcat_think>")
|
||||
if len(parts) > 1 {
|
||||
return strings.TrimSpace(parts[1])
|
||||
}
|
||||
// If no closing tag, try to extract after the thinking content
|
||||
lines := strings.Split(content, "\n")
|
||||
for i, line := range lines {
|
||||
if strings.Contains(line, "</longcat_think>") {
|
||||
return strings.TrimSpace(strings.Join(lines[i+1:], "\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return content
|
||||
} else if resp.Choices[0].Message.ReasoningContent != "" {
|
||||
// For LongCat-Flash-Thinking-2601, check if there's actual content
|
||||
// This model puts reasoning in reasoning_content and final answer in content
|
||||
// If content is null, we might need to extract from reasoning or return the reasoning itself
|
||||
return resp.Choices[0].Message.ReasoningContent
|
||||
}
|
||||
}
|
||||
|
||||
// For Grok, DeepSeek, Mistral and other providers, or if no special handling needed
|
||||
return resp.Choices[0].Message.Content
|
||||
}
|
||||
|
||||
// getThinkingModel returns the appropriate thinking model for the provider
|
||||
func (s *AIService) getThinkingModel() string {
|
||||
switch s.provider {
|
||||
case ProviderMistral:
|
||||
return os.Getenv("MISTRAL_MODEL_THINKING")
|
||||
case ProviderLongCat:
|
||||
return os.Getenv("LONGCAT_MODEL_THINKING")
|
||||
case ProviderGrok:
|
||||
return os.Getenv("GROK_MODEL_THINKING")
|
||||
case ProviderDeepSeek:
|
||||
return os.Getenv("DEEPSEEK_MODEL_THINKING")
|
||||
case ProviderOllama:
|
||||
return os.Getenv("OLLAMA_MODEL_THINKING")
|
||||
case ProviderOpenRouter:
|
||||
return os.Getenv("OPENROUTER_MODEL_THINKING")
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// callOpenRouter calls the OpenRouter API (OpenAI-compatible)
|
||||
func (s *AIService) callOpenRouter(req AIRequest) (*AIResponse, error) {
|
||||
apiKey := os.Getenv("OPENROUTER_API_KEY")
|
||||
baseURL := os.Getenv("OPENROUTER_BASE_URL")
|
||||
if baseURL == "" {
|
||||
baseURL = "https://openrouter.ai/api"
|
||||
}
|
||||
|
||||
model := os.Getenv("OPENROUTER_MODEL")
|
||||
if model == "" {
|
||||
model = "openrouter/auto"
|
||||
}
|
||||
|
||||
if req.Model == "" {
|
||||
req.Model = model
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequest("POST", baseURL+"/v1/chat/completions", strings.NewReader(string(jsonData)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
if apiKey != "" {
|
||||
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("OpenRouter API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var orResp AIResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&orResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &orResp, nil
|
||||
}
|
||||
|
||||
// callMistral calls Mistral AI API
|
||||
func (s *AIService) callMistral(req AIRequest) (*AIResponse, error) {
|
||||
apiKey := os.Getenv("MISTRAL_API_KEY")
|
||||
baseURL := "https://api.mistral.ai/v1"
|
||||
model := os.Getenv("MISTRAL_MODEL")
|
||||
if model == "" {
|
||||
model = "mistral-small-latest"
|
||||
}
|
||||
|
||||
if req.Model == "" {
|
||||
req.Model = model
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequest("POST", baseURL+"/chat/completions", strings.NewReader(string(jsonData)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("Mistral API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var mistralResp AIResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&mistralResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &mistralResp, nil
|
||||
}
|
||||
|
||||
// callLongCat calls LongCat AI API
|
||||
func (s *AIService) callLongCat(req AIRequest) (*AIResponse, error) {
|
||||
apiKey := os.Getenv("LONGCAT_API_KEY")
|
||||
|
||||
// Determine format and endpoint
|
||||
format := os.Getenv("LONGCAT_FORMAT")
|
||||
if format == "" {
|
||||
format = "openai" // Default to OpenAI format
|
||||
}
|
||||
|
||||
var baseURL string
|
||||
switch format {
|
||||
case "openai":
|
||||
baseURL = "https://api.longcat.chat/openai"
|
||||
case "anthropic":
|
||||
baseURL = "https://api.longcat.chat/anthropic"
|
||||
default:
|
||||
baseURL = "https://api.longcat.chat/openai"
|
||||
}
|
||||
|
||||
model := os.Getenv("LONGCAT_MODEL")
|
||||
if model == "" {
|
||||
model = "LongCat-Flash-Chat"
|
||||
}
|
||||
|
||||
if req.Model == "" {
|
||||
req.Model = model
|
||||
}
|
||||
|
||||
var jsonBody []byte
|
||||
var httpReq *http.Request
|
||||
var err error
|
||||
|
||||
if format == "anthropic" {
|
||||
// Convert to Anthropic format
|
||||
anthropicReq := map[string]interface{}{
|
||||
"model": req.Model,
|
||||
"max_tokens": req.MaxTokens,
|
||||
"messages": req.Messages,
|
||||
}
|
||||
if req.Temperature > 0 {
|
||||
anthropicReq["temperature"] = req.Temperature
|
||||
}
|
||||
|
||||
jsonBody, err = json.Marshal(anthropicReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq, err = http.NewRequest("POST", baseURL+"/v1/messages", strings.NewReader(string(jsonBody)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
httpReq.Header.Set("anthropic-version", "2023-06-01")
|
||||
} else {
|
||||
// OpenAI format
|
||||
jsonBody, err = json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq, err = http.NewRequest("POST", baseURL+"/v1/chat/completions", strings.NewReader(string(jsonBody)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("LongCat API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var longcatResp AIResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&longcatResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &longcatResp, nil
|
||||
}
|
||||
|
||||
// callGrok calls Grok AI API
|
||||
func (s *AIService) callGrok(req AIRequest) (*AIResponse, error) {
|
||||
apiKey := os.Getenv("GROK_API_KEY")
|
||||
baseURL := os.Getenv("GROK_BASE_URL")
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.x.ai/v1"
|
||||
}
|
||||
|
||||
model := os.Getenv("GROK_MODEL")
|
||||
if model == "" {
|
||||
model = "grok-beta"
|
||||
}
|
||||
|
||||
if req.Model == "" {
|
||||
req.Model = model
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequest("POST", baseURL+"/chat/completions", strings.NewReader(string(jsonData)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("Grok API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var grokResp AIResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&grokResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &grokResp, nil
|
||||
}
|
||||
|
||||
// callDeepSeek calls DeepSeek API
|
||||
func (s *AIService) callDeepSeek(req AIRequest) (*AIResponse, error) {
|
||||
apiKey := os.Getenv("DEEPSEEK_API_KEY")
|
||||
baseURL := os.Getenv("DEEPSEEK_BASE_URL")
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.deepseek.com"
|
||||
}
|
||||
|
||||
model := os.Getenv("DEEPSEEK_MODEL")
|
||||
if model == "" {
|
||||
model = "deepseek-chat"
|
||||
}
|
||||
|
||||
if req.Model == "" {
|
||||
req.Model = model
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequest("POST", baseURL+"/chat/completions", strings.NewReader(string(jsonData)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("DeepSeek API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var deepseekResp AIResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&deepseekResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &deepseekResp, nil
|
||||
}
|
||||
|
||||
// callOllama calls Ollama API
|
||||
func (s *AIService) callOllama(req AIRequest) (*AIResponse, error) {
|
||||
baseURL := os.Getenv("OLLAMA_BASE_URL")
|
||||
if baseURL == "" {
|
||||
baseURL = "http://localhost:11434"
|
||||
}
|
||||
|
||||
model := os.Getenv("OLLAMA_MODEL")
|
||||
if model == "" {
|
||||
model = "llama3.1"
|
||||
}
|
||||
|
||||
if req.Model == "" {
|
||||
req.Model = model
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequest("POST", baseURL+"/api/chat", strings.NewReader(string(jsonData)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 60 * time.Second} // Ollama can be slower
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("Ollama API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var ollamaResp AIResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&ollamaResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ollamaResp, nil
|
||||
}
|
||||
|
||||
// SetProvider changes the AI provider
|
||||
func (s *AIService) SetProvider(provider AIProvider) {
|
||||
s.provider = provider
|
||||
}
|
||||
|
||||
// GetProvider returns the current AI provider
|
||||
func (s *AIService) GetProvider() AIProvider {
|
||||
return s.provider
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// WsEvent is a realtime event payload emitted by the messaging hub.
|
||||
type WsEvent struct {
|
||||
Type string `json:"type"`
|
||||
ConversationID uint `json:"conversation_id,omitempty"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
// MessagesWSClient represents one websocket connection.
|
||||
type MessagesWSClient struct {
|
||||
UserID uint
|
||||
Conn *websocket.Conn
|
||||
Send chan []byte
|
||||
Conversations map[uint]struct{}
|
||||
}
|
||||
|
||||
// MessagesHub coordinates room-based websocket fanout.
|
||||
type MessagesHub struct {
|
||||
mu sync.RWMutex
|
||||
conversationClients map[uint]map[*MessagesWSClient]struct{}
|
||||
clientConversations map[*MessagesWSClient]map[uint]struct{}
|
||||
}
|
||||
|
||||
var defaultMessagesHub = NewMessagesHub()
|
||||
|
||||
// GetMessagesHub returns the shared messaging websocket hub.
|
||||
func GetMessagesHub() *MessagesHub {
|
||||
return defaultMessagesHub
|
||||
}
|
||||
|
||||
// NewMessagesHub creates a new messages websocket hub.
|
||||
func NewMessagesHub() *MessagesHub {
|
||||
return &MessagesHub{
|
||||
conversationClients: make(map[uint]map[*MessagesWSClient]struct{}),
|
||||
clientConversations: make(map[*MessagesWSClient]map[uint]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// NewWSClient creates a ws client wrapper.
|
||||
func NewWSClient(userID uint, conn *websocket.Conn) *MessagesWSClient {
|
||||
return &MessagesWSClient{
|
||||
UserID: userID,
|
||||
Conn: conn,
|
||||
Send: make(chan []byte, 128),
|
||||
Conversations: make(map[uint]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// AddClientToConversation subscribes a client to a conversation room.
|
||||
func (h *MessagesHub) AddClientToConversation(client *MessagesWSClient, conversationID uint) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if _, exists := h.conversationClients[conversationID]; !exists {
|
||||
h.conversationClients[conversationID] = make(map[*MessagesWSClient]struct{})
|
||||
}
|
||||
h.conversationClients[conversationID][client] = struct{}{}
|
||||
|
||||
if _, exists := h.clientConversations[client]; !exists {
|
||||
h.clientConversations[client] = make(map[uint]struct{})
|
||||
}
|
||||
h.clientConversations[client][conversationID] = struct{}{}
|
||||
client.Conversations[conversationID] = struct{}{}
|
||||
}
|
||||
|
||||
// RemoveClientFromConversation unsubscribes a client from one room.
|
||||
func (h *MessagesHub) RemoveClientFromConversation(client *MessagesWSClient, conversationID uint) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if clients, exists := h.conversationClients[conversationID]; exists {
|
||||
delete(clients, client)
|
||||
if len(clients) == 0 {
|
||||
delete(h.conversationClients, conversationID)
|
||||
}
|
||||
}
|
||||
|
||||
if convs, exists := h.clientConversations[client]; exists {
|
||||
delete(convs, conversationID)
|
||||
if len(convs) == 0 {
|
||||
delete(h.clientConversations, client)
|
||||
}
|
||||
}
|
||||
delete(client.Conversations, conversationID)
|
||||
}
|
||||
|
||||
// RemoveClient fully unregisters a client from all rooms.
|
||||
func (h *MessagesHub) RemoveClient(client *MessagesWSClient) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if convs, exists := h.clientConversations[client]; exists {
|
||||
for convID := range convs {
|
||||
if clients, ok := h.conversationClients[convID]; ok {
|
||||
delete(clients, client)
|
||||
if len(clients) == 0 {
|
||||
delete(h.conversationClients, convID)
|
||||
}
|
||||
}
|
||||
}
|
||||
delete(h.clientConversations, client)
|
||||
}
|
||||
|
||||
close(client.Send)
|
||||
}
|
||||
|
||||
// Broadcast emits an event to all clients in one conversation room.
|
||||
func (h *MessagesHub) Broadcast(conversationID uint, eventType string, data interface{}) {
|
||||
event := WsEvent{
|
||||
Type: eventType,
|
||||
ConversationID: conversationID,
|
||||
Data: data,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
h.mu.RLock()
|
||||
clients := h.conversationClients[conversationID]
|
||||
h.mu.RUnlock()
|
||||
|
||||
for client := range clients {
|
||||
select {
|
||||
case client.Send <- raw:
|
||||
default:
|
||||
go h.RemoveClient(client)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SendToUser emits an event to one user in a conversation room.
|
||||
func (h *MessagesHub) SendToUser(conversationID, userID uint, eventType string, data interface{}) {
|
||||
event := WsEvent{
|
||||
Type: eventType,
|
||||
ConversationID: conversationID,
|
||||
Data: data,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
h.mu.RLock()
|
||||
clients := h.conversationClients[conversationID]
|
||||
h.mu.RUnlock()
|
||||
|
||||
for client := range clients {
|
||||
if client.UserID != userID {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case client.Send <- raw:
|
||||
default:
|
||||
go h.RemoveClient(client)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user