diff --git a/.env.example b/.env.example index 7510d285..683eb5c1 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,10 @@ JWT_REFRESH_TOKEN_EXPIRE_DAYS=7 OPENAI_API_KEY= OPENAI_BASE_URL=https://api-inference.modelscope.cn/v1 OPENAI_MODEL=Qwen/Qwen3-235B-A22B +IMAGE_MODEL=Tongyi-MAI/Z-Image-Turbo + +# ==================== Firecrawl (Web Search) ==================== +FIRECRAWL_API_KEY= # ==================== Server ==================== PORT=8000 diff --git a/CHANGELOG.md b/CHANGELOG.md index 341fb0c0..dae3f2f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +#### Vue 3 Frontend + +- **Background music loop**: Added global alternating playback for `The Shore and You` and `Travelogue` +- **Profile music control**: Added background music volume slider in the profile settings panel + +### Changed + +- **Frontend assets**: Reorganized static assets into `frontend/src/assets/images/` and `frontend/src/assets/audio/` +- **Asset imports**: Updated frontend image and audio references to match the new asset directory structure +- **Crab speech bubble anchoring**: Reworked the beach scene crab hint bubble to position from the crab sprite's rendered bounds instead of static offsets, keeping the bubble visually closer across viewport sizes. +--- + ## [1.0.0] - 2026-03-05 ### Major Rewrite diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index c466e217..fcfa4151 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -13,6 +13,7 @@ import ( "github.com/momshell/backend/internal/repository" "github.com/momshell/backend/internal/router" "github.com/momshell/backend/internal/service" + "github.com/momshell/backend/pkg/firecrawl" "github.com/momshell/backend/pkg/openai" "github.com/momshell/backend/pkg/password" ) @@ -46,6 +47,9 @@ func main() { tagRepo := repository.NewTagRepo(db) chatRepo := repository.NewChatRepo(db) echoRepo := repository.NewEchoRepo(db) + photoRepo := repository.NewPhotoRepo(db) + whisperRepo := repository.NewWhisperRepo(db) + taskRepo := repository.NewTaskRepo(db) // Initialize services moderationService := service.NewModerationService() @@ -63,8 +67,28 @@ func main() { log.Println("[WARN] OPENAI_API_KEY not set, chat service will not work") chatClient = openai.NewClient("dummy", cfg.OpenAIBaseURL, cfg.OpenAIModel) } - chatService := service.NewChatService(chatClient, chatRepo) + + var firecrawlClient *firecrawl.Client + if cfg.FirecrawlAPIKey != "" { + firecrawlClient = firecrawl.NewClient(cfg.FirecrawlAPIKey) + } + + chatService := service.NewChatService(chatClient, chatRepo, firecrawlClient) echoService := service.NewEchoService(chatClient, echoRepo, userRepo) + photoService := service.NewPhotoService(photoRepo, chatClient, cfg.ImageModel) + whisperService := service.NewWhisperService(whisperRepo, userRepo, chatClient) + taskService := service.NewTaskService(taskRepo, userRepo) + + // Ensure AI user exists for community AI replies + aiUserID := ensureAIUser(userRepo) + var communityAIService *service.CommunityAIService + if aiUserID != "" && cfg.OpenAIAPIKey != "" { + communityAIService = service.NewCommunityAIService( + chatClient, firecrawlClient, + questionRepo, answerRepo, commentRepo, + aiUserID, + ) + } userService := service.NewUserService( db, userRepo, questionRepo, answerRepo, @@ -77,15 +101,18 @@ func main() { // Initialize handlers authHandler := handler.NewAuthHandler(authService) - questionHandler := handler.NewQuestionHandler(communityService, authService) - answerHandler := handler.NewAnswerHandler(communityService, authService) - commentHandler := handler.NewCommentHandler(communityService, authService) + questionHandler := handler.NewQuestionHandler(communityService, authService, communityAIService) + answerHandler := handler.NewAnswerHandler(communityService, authService, communityAIService) + commentHandler := handler.NewCommentHandler(communityService, authService, communityAIService) interactionHandler := handler.NewInteractionHandler(communityService) tagHandler := handler.NewTagHandler(communityService) chatHandler := handler.NewChatHandler(chatService) echoHandler := handler.NewEchoHandler(echoService) userHandler := handler.NewUserHandler(userService) adminHandler := handler.NewAdminHandler(adminService, authService) + photoHandler := handler.NewPhotoHandler(photoService) + whisperHandler := handler.NewWhisperHandler(whisperService) + taskHandler := handler.NewTaskHandler(taskService) // Setup Gin r := gin.New() @@ -99,6 +126,7 @@ func main() { authHandler, questionHandler, answerHandler, commentHandler, interactionHandler, tagHandler, chatHandler, echoHandler, userHandler, adminHandler, + photoHandler, whisperHandler, taskHandler, ) // Start server @@ -143,3 +171,33 @@ func createInitialAdmin(cfg *config.Config, userRepo *repository.UserRepo) { log.Printf("Admin user created: %s", cfg.AdminUsername) } + +func ensureAIUser(userRepo *repository.UserRepo) string { + user, err := userRepo.FindByUsernameOrEmail("xiaoshiguang") + if err == nil { + return user.ID + } + + hash, err := password.Hash("ai-user-no-login") + if err != nil { + log.Printf("Failed to hash AI user password: %v", err) + return "" + } + + aiUser := &model.User{ + Username: "xiaoshiguang", + Email: "ai@momshell.com", + PasswordHash: hash, + Nickname: "小石光", + Role: model.RoleAIAssistant, + IsActive: true, + } + + if err := userRepo.Create(aiUser); err != nil { + log.Printf("Failed to create AI user: %v", err) + return "" + } + + log.Printf("AI user created: %s (ID: %s)", aiUser.Username, aiUser.ID) + return aiUser.ID +} diff --git a/backend/internal/admin/admin.html b/backend/internal/admin/admin.html index ec510ccb..2db5bdf6 100644 --- a/backend/internal/admin/admin.html +++ b/backend/internal/admin/admin.html @@ -475,7 +475,7 @@

确认删除

headers: { 'Authorization': 'Bearer ' + data.access_token } }); const meData = await meResp.json(); - if (!meResp.ok || meData.role !== 'admin') { + if (!meResp.ok || !meData.is_admin) { throw new Error('需要管理员权限'); } diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index fb6a0b66..761fbe40 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -22,6 +22,10 @@ type Config struct { OpenAIAPIKey string OpenAIBaseURL string OpenAIModel string + ImageModel string + + // Firecrawl (web search) + FirecrawlAPIKey string // Server Port string @@ -46,6 +50,8 @@ func Load() *Config { OpenAIAPIKey: getEnv("OPENAI_API_KEY", ""), OpenAIBaseURL: getEnv("OPENAI_BASE_URL", "https://api-inference.modelscope.cn/v1"), OpenAIModel: getEnv("OPENAI_MODEL", "Qwen/Qwen2.5-72B-Instruct"), + FirecrawlAPIKey: getEnv("FIRECRAWL_API_KEY", ""), + ImageModel: getEnv("IMAGE_MODEL", ""), Port: getEnv("PORT", "8000"), AdminUsername: getEnv("ADMIN_USERNAME", ""), AdminEmail: getEnv("ADMIN_EMAIL", ""), diff --git a/backend/internal/database/migrate.go b/backend/internal/database/migrate.go index 78580b4c..e3c248c5 100644 --- a/backend/internal/database/migrate.go +++ b/backend/internal/database/migrate.go @@ -20,10 +20,17 @@ func Migrate(db *gorm.DB) error { &model.ChatMemory{}, &model.IdentityTag{}, &model.Memoir{}, + &model.Photo{}, + &model.Whisper{}, + &model.DailyTask{}, + &model.UserTask{}, ); err != nil { return err } + // Seed default daily tasks if none exist + seedDailyTasks(db) + // Migrate legacy role='admin' users to is_admin flag db.Model(&model.User{}).Where("role = ?", "admin").Updates(map[string]interface{}{ "is_admin": true, @@ -32,3 +39,42 @@ func Migrate(db *gorm.DB) error { return nil } + +func seedDailyTasks(db *gorm.DB) { + var count int64 + db.Model(&model.DailyTask{}).Count(&count) + if count > 0 { + return + } + + tasks := []model.DailyTask{ + // Housework + {Title: "做一顿早餐", Description: "为家人准备一份营养早餐", Category: model.TaskCategoryHousework, Difficulty: 2}, + {Title: "整理卧室", Description: "整理床铺、叠好衣物", Category: model.TaskCategoryHousework, Difficulty: 1}, + {Title: "打扫客厅", Description: "扫地、拖地、整理杂物", Category: model.TaskCategoryHousework, Difficulty: 2}, + {Title: "清洁厨房", Description: "洗碗、擦台面、清理灶台", Category: model.TaskCategoryHousework, Difficulty: 2}, + {Title: "洗一次衣服", Description: "把脏衣服分类洗好、晾晒", Category: model.TaskCategoryHousework, Difficulty: 2}, + {Title: "倒垃圾", Description: "把垃圾分类打包扔掉", Category: model.TaskCategoryHousework, Difficulty: 1}, + // Parenting + {Title: "学习婴儿急救知识", Description: "看一篇婴儿急救相关的文章或视频", Category: model.TaskCategoryParenting, Difficulty: 3}, + {Title: "了解宝宝辅食添加", Description: "学习适龄辅食的种类和注意事项", Category: model.TaskCategoryParenting, Difficulty: 2}, + {Title: "学习安抚哭闹技巧", Description: "学习如何科学安抚宝宝", Category: model.TaskCategoryParenting, Difficulty: 2}, + {Title: "阅读一篇育儿文章", Description: "选一篇科学育儿知识文章阅读", Category: model.TaskCategoryParenting, Difficulty: 1}, + {Title: "陪宝宝互动 15 分钟", Description: "放下手机,全身心陪伴宝宝玩耍", Category: model.TaskCategoryParenting, Difficulty: 2}, + // Health + {Title: "陪妈妈散步 30 分钟", Description: "陪伴一起户外走走,呼吸新鲜空气", Category: model.TaskCategoryHealth, Difficulty: 2}, + {Title: "提醒妈妈按时吃饭", Description: "确保她三餐按时吃、营养均衡", Category: model.TaskCategoryHealth, Difficulty: 1}, + {Title: "帮妈妈做肩颈按摩", Description: "帮她放松一下肩颈,缓解疲劳", Category: model.TaskCategoryHealth, Difficulty: 2}, + {Title: "提醒妈妈喝水", Description: "关心她的饮水量,适时提醒", Category: model.TaskCategoryHealth, Difficulty: 1}, + // Emotional + {Title: "给妈妈写一段鼓励的话", Description: "用文字表达你对她的感谢和支持", Category: model.TaskCategoryEmotional, Difficulty: 3}, + {Title: "主动询问她今天的感受", Description: "认真倾听她的心情,不急着给建议", Category: model.TaskCategoryEmotional, Difficulty: 2}, + {Title: "准备一个小惊喜", Description: "一杯热饮、一束花、或一句暖心的话", Category: model.TaskCategoryEmotional, Difficulty: 3}, + {Title: "主动承担一次夜间照顾", Description: "让妈妈好好休息一晚", Category: model.TaskCategoryEmotional, Difficulty: 4}, + {Title: "表达一次感谢", Description: "告诉她你看到了她的付出和辛苦", Category: model.TaskCategoryEmotional, Difficulty: 1}, + } + + for i := range tasks { + db.Create(&tasks[i]) + } +} diff --git a/backend/internal/dto/photo.go b/backend/internal/dto/photo.go new file mode 100644 index 00000000..56c38f79 --- /dev/null +++ b/backend/internal/dto/photo.go @@ -0,0 +1,46 @@ +package dto + +type PhotoResponse struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Tags []string `json:"tags"` + ImageURL string `json:"image_url"` + IsOnWall bool `json:"is_on_wall"` + WallPosition *int `json:"wall_position"` + Source string `json:"source"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type PhotoListResponse struct { + Photos []PhotoResponse `json:"photos"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` + TotalPages int `json:"total_pages"` +} + +type GeneratePhotoRequest struct { + Prompt string `json:"prompt" binding:"required,min=1,max=500"` +} + +type UpdatePhotoRequest struct { + Title *string `json:"title" binding:"omitempty,max=200"` + Description *string `json:"description" binding:"omitempty,max=2000"` + Tags []string `json:"tags" binding:"omitempty,max=10"` +} + +type ToggleWallRequest struct { + IsOnWall bool `json:"is_on_wall"` + WallPosition *int `json:"wall_position" binding:"omitempty,min=0,max=8"` +} + +type BatchWallUpdateRequest struct { + Photos []WallItem `json:"photos" binding:"required,min=1,max=9"` +} + +type WallItem struct { + PhotoID string `json:"photo_id" binding:"required"` + Position int `json:"position" binding:"min=0,max=8"` +} diff --git a/backend/internal/dto/task.go b/backend/internal/dto/task.go new file mode 100644 index 00000000..08b92a2a --- /dev/null +++ b/backend/internal/dto/task.go @@ -0,0 +1,27 @@ +package dto + +import "time" + +type UserTaskItem struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Category string `json:"category"` + Difficulty int `json:"difficulty"` + Status string `json:"status"` + Score *int `json:"score"` + Comment *string `json:"comment"` + CompletedAt *time.Time `json:"completed_at"` + ScoredAt *time.Time `json:"scored_at"` + Date time.Time `json:"date"` +} + +type TaskScore struct { + Score int `json:"score" binding:"required,min=1,max=5"` + Comment string `json:"comment" binding:"max=500"` +} + +type TaskStats struct { + XP int `json:"xp"` + Level int `json:"level"` +} diff --git a/backend/internal/dto/whisper.go b/backend/internal/dto/whisper.go new file mode 100644 index 00000000..3234576d --- /dev/null +++ b/backend/internal/dto/whisper.go @@ -0,0 +1,18 @@ +package dto + +import "time" + +type WhisperCreate struct { + Content string `json:"content" binding:"required,min=1,max=2000"` +} + +type WhisperItem struct { + ID string `json:"id"` + Content string `json:"content"` + CreatedAt time.Time `json:"created_at"` +} + +type WhisperTips struct { + Tips string `json:"tips"` + Whispers []WhisperItem `json:"whispers"` +} diff --git a/backend/internal/handler/answer.go b/backend/internal/handler/answer.go index 9239a24a..9b33c554 100644 --- a/backend/internal/handler/answer.go +++ b/backend/internal/handler/answer.go @@ -12,10 +12,11 @@ import ( type AnswerHandler struct { communityService *service.CommunityService authService *service.AuthService + communityAI *service.CommunityAIService } -func NewAnswerHandler(communityService *service.CommunityService, authService *service.AuthService) *AnswerHandler { - return &AnswerHandler{communityService: communityService, authService: authService} +func NewAnswerHandler(communityService *service.CommunityService, authService *service.AuthService, communityAI *service.CommunityAIService) *AnswerHandler { + return &AnswerHandler{communityService: communityService, authService: authService, communityAI: communityAI} } // GET /api/v1/community/questions/:id/answers @@ -67,6 +68,11 @@ func (h *AnswerHandler) Create(c *gin.Context) { return } + // Trigger AI reply if answer mentions @小石光 + if h.communityAI != nil && service.ContainsMention(req.Content) { + go h.communityAI.HandleNewAnswer(answer.QuestionID, answer.ID) + } + c.JSON(http.StatusCreated, gin.H{"id": answer.ID, "status": string(answer.Status)}) } diff --git a/backend/internal/handler/comment.go b/backend/internal/handler/comment.go index 02dec0cd..3d62b793 100644 --- a/backend/internal/handler/comment.go +++ b/backend/internal/handler/comment.go @@ -12,10 +12,11 @@ import ( type CommentHandler struct { communityService *service.CommunityService authService *service.AuthService + communityAI *service.CommunityAIService } -func NewCommentHandler(communityService *service.CommunityService, authService *service.AuthService) *CommentHandler { - return &CommentHandler{communityService: communityService, authService: authService} +func NewCommentHandler(communityService *service.CommunityService, authService *service.AuthService, communityAI *service.CommunityAIService) *CommentHandler { + return &CommentHandler{communityService: communityService, authService: authService, communityAI: communityAI} } // GET /api/v1/community/answers/:id/comments @@ -59,6 +60,11 @@ func (h *CommentHandler) Create(c *gin.Context) { return } + // Trigger AI reply if commenting on AI's answer, replying to AI, or @mentioning AI + if h.communityAI != nil && h.communityAI.ShouldReplyToComment(req.Content, answerID, req.ParentID) { + go h.communityAI.HandleNewComment(answerID, comment.ID) + } + c.JSON(http.StatusCreated, comment) } diff --git a/backend/internal/handler/photo.go b/backend/internal/handler/photo.go new file mode 100644 index 00000000..fd7b3e7f --- /dev/null +++ b/backend/internal/handler/photo.go @@ -0,0 +1,204 @@ +package handler + +import ( + "context" + "fmt" + "net/http" + "os" + "path/filepath" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/momshell/backend/internal/dto" + "github.com/momshell/backend/internal/middleware" + "github.com/momshell/backend/internal/service" +) + +const maxPhotoSize = 5 << 20 // 5 MB + +var allowedPhotoTypes = map[string]bool{ + "image/jpeg": true, + "image/png": true, + "image/gif": true, + "image/webp": true, +} + +type PhotoHandler struct { + photoService *service.PhotoService +} + +func NewPhotoHandler(photoService *service.PhotoService) *PhotoHandler { + return &PhotoHandler{photoService: photoService} +} + +// GET /api/v1/photos +func (h *PhotoHandler) List(c *gin.Context) { + userID := middleware.GetUserID(c) + + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + result, err := h.photoService.ListPhotos(userID, page, pageSize) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, result) +} + +// POST /api/v1/photos/upload +func (h *PhotoHandler) Upload(c *gin.Context) { + userID := middleware.GetUserID(c) + + file, header, err := c.Request.FormFile("photo") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请选择要上传的照片"}) + return + } + defer func() { _ = file.Close() }() + + if header.Size > maxPhotoSize { + c.JSON(http.StatusBadRequest, gin.H{"error": "照片大小不能超过 5MB"}) + return + } + + contentType := header.Header.Get("Content-Type") + if !allowedPhotoTypes[contentType] { + c.JSON(http.StatusBadRequest, gin.H{"error": "仅支持 JPG、PNG、GIF、WebP 格式"}) + return + } + + ext := ".jpg" + switch contentType { + case "image/png": + ext = ".png" + case "image/gif": + ext = ".gif" + case "image/webp": + ext = ".webp" + } + + uploadDir := "uploads/photos" + if err := os.MkdirAll(uploadDir, 0o755); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "上传失败"}) + return + } + + filename := uuid.New().String() + ext + savePath := filepath.Join(uploadDir, filename) + + if err := c.SaveUploadedFile(header, savePath); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "上传失败"}) + return + } + + imageURL := fmt.Sprintf("/uploads/photos/%s", filename) + title := c.PostForm("title") + + result, err := h.photoService.CreateFromUpload(userID, title, imageURL) + if err != nil { + _ = os.Remove(savePath) + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, result) +} + +// POST /api/v1/photos/generate +func (h *PhotoHandler) Generate(c *gin.Context) { + userID := middleware.GetUserID(c) + + var req dto.GeneratePhotoRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Image generation may need async polling, allow up to 5 minutes + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + result, err := h.photoService.GeneratePhoto(ctx, userID, req) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, result) +} + +// PUT /api/v1/photos/:id +func (h *PhotoHandler) Update(c *gin.Context) { + userID := middleware.GetUserID(c) + photoID := c.Param("id") + + var req dto.UpdatePhotoRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + result, err := h.photoService.UpdatePhoto(photoID, userID, req) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, result) +} + +// DELETE /api/v1/photos/:id +func (h *PhotoHandler) Delete(c *gin.Context) { + userID := middleware.GetUserID(c) + photoID := c.Param("id") + + if err := h.photoService.DeletePhoto(photoID, userID); err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusNoContent, nil) +} + +// PUT /api/v1/photos/:id/wall +func (h *PhotoHandler) ToggleWall(c *gin.Context) { + userID := middleware.GetUserID(c) + photoID := c.Param("id") + + var req dto.ToggleWallRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + result, err := h.photoService.ToggleWall(photoID, userID, req) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, result) +} + +// PUT /api/v1/photos/wall +func (h *PhotoHandler) BatchUpdateWall(c *gin.Context) { + userID := middleware.GetUserID(c) + + var req dto.BatchWallUpdateRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + result, err := h.photoService.BatchUpdateWall(userID, req) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"photos": result}) +} diff --git a/backend/internal/handler/question.go b/backend/internal/handler/question.go index ad396881..bb9a8e59 100644 --- a/backend/internal/handler/question.go +++ b/backend/internal/handler/question.go @@ -6,16 +6,18 @@ import ( "github.com/gin-gonic/gin" "github.com/momshell/backend/internal/dto" "github.com/momshell/backend/internal/middleware" + "github.com/momshell/backend/internal/model" "github.com/momshell/backend/internal/service" ) type QuestionHandler struct { communityService *service.CommunityService authService *service.AuthService + communityAI *service.CommunityAIService } -func NewQuestionHandler(communityService *service.CommunityService, authService *service.AuthService) *QuestionHandler { - return &QuestionHandler{communityService: communityService, authService: authService} +func NewQuestionHandler(communityService *service.CommunityService, authService *service.AuthService, communityAI *service.CommunityAIService) *QuestionHandler { + return &QuestionHandler{communityService: communityService, authService: authService, communityAI: communityAI} } // GET /api/v1/community/questions @@ -122,6 +124,11 @@ func (h *QuestionHandler) Create(c *gin.Context) { return } + // Trigger AI reply for every new published question + if h.communityAI != nil && question.Status == model.StatusPublished { + go h.communityAI.HandleNewQuestion(question.ID) + } + c.JSON(http.StatusCreated, gin.H{"id": question.ID, "status": string(question.Status)}) } diff --git a/backend/internal/handler/task.go b/backend/internal/handler/task.go new file mode 100644 index 00000000..4a243d32 --- /dev/null +++ b/backend/internal/handler/task.go @@ -0,0 +1,125 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/momshell/backend/internal/dto" + "github.com/momshell/backend/internal/middleware" + "github.com/momshell/backend/internal/service" +) + +type TaskHandler struct { + taskService *service.TaskService +} + +func NewTaskHandler(taskService *service.TaskService) *TaskHandler { + return &TaskHandler{taskService: taskService} +} + +// GET /api/v1/tasks/daily +func (h *TaskHandler) DailyTasks(c *gin.Context) { + userID := middleware.GetUserID(c) + items, err := h.taskService.GetDailyTasks(userID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, items) +} + +// POST /api/v1/tasks/:id/complete +func (h *TaskHandler) Complete(c *gin.Context) { + taskID := c.Param("id") + userID := middleware.GetUserID(c) + + item, err := h.taskService.CompleteTask(userID, taskID) + if err != nil { + status := http.StatusBadRequest + if err.Error() == "任务不存在" { + status = http.StatusNotFound + } else if err.Error() == "无权操作此任务" { + status = http.StatusForbidden + } + c.JSON(status, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, item) +} + +// GET /api/v1/tasks/partner +func (h *TaskHandler) PartnerTasks(c *gin.Context) { + userID := middleware.GetUserID(c) + items, err := h.taskService.GetPartnerTasks(userID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, items) +} + +// POST /api/v1/tasks/:id/score +func (h *TaskHandler) Score(c *gin.Context) { + taskID := c.Param("id") + userID := middleware.GetUserID(c) + + var req dto.TaskScore + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + item, err := h.taskService.ScoreTask(userID, taskID, req) + if err != nil { + status := http.StatusBadRequest + if err.Error() == "任务不存在" { + status = http.StatusNotFound + } else if err.Error() == "只能验收伴侣的任务" || err.Error() == "只有妈妈角色可以验收任务" { + status = http.StatusForbidden + } + c.JSON(status, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, item) +} + +// POST /api/v1/tasks/:id/reject +func (h *TaskHandler) Reject(c *gin.Context) { + taskID := c.Param("id") + userID := middleware.GetUserID(c) + + var req struct { + Comment string `json:"comment" binding:"max=500"` + } + _ = c.ShouldBindJSON(&req) + + item, err := h.taskService.RejectTask(userID, taskID, req.Comment) + if err != nil { + status := http.StatusBadRequest + if err.Error() == "任务不存在" { + status = http.StatusNotFound + } else if err.Error() == "只能验收伴侣的任务" || err.Error() == "只有妈妈角色可以验收任务" { + status = http.StatusForbidden + } + c.JSON(status, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, item) +} + +// GET /api/v1/tasks/stats +func (h *TaskHandler) Stats(c *gin.Context) { + userID := middleware.GetUserID(c) + stats, err := h.taskService.GetTaskStats(userID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, stats) +} diff --git a/backend/internal/handler/whisper.go b/backend/internal/handler/whisper.go new file mode 100644 index 00000000..62a51db1 --- /dev/null +++ b/backend/internal/handler/whisper.go @@ -0,0 +1,64 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/momshell/backend/internal/dto" + "github.com/momshell/backend/internal/middleware" + "github.com/momshell/backend/internal/service" +) + +type WhisperHandler struct { + whisperService *service.WhisperService +} + +func NewWhisperHandler(whisperService *service.WhisperService) *WhisperHandler { + return &WhisperHandler{whisperService: whisperService} +} + +// POST /api/v1/whisper +func (h *WhisperHandler) Create(c *gin.Context) { + var req dto.WhisperCreate + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + userID := middleware.GetUserID(c) + item, err := h.whisperService.CreateWhisper(userID, req.Content) + if err != nil { + status := http.StatusBadRequest + if err.Error() == "用户不存在" { + status = http.StatusUnauthorized + } + c.JSON(status, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, item) +} + +// GET /api/v1/whisper +func (h *WhisperHandler) List(c *gin.Context) { + userID := middleware.GetUserID(c) + items, err := h.whisperService.GetWhispers(userID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, items) +} + +// GET /api/v1/whisper/tips +func (h *WhisperHandler) Tips(c *gin.Context) { + userID := middleware.GetUserID(c) + tips, err := h.whisperService.GetWhisperTips(userID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, tips) +} diff --git a/backend/internal/model/photo.go b/backend/internal/model/photo.go new file mode 100644 index 00000000..ceb0aee8 --- /dev/null +++ b/backend/internal/model/photo.go @@ -0,0 +1,31 @@ +package model + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type Photo struct { + ID string `gorm:"type:varchar(36);primaryKey" json:"id"` + UserID string `gorm:"type:varchar(36);index;not null" json:"-"` + Title string `gorm:"type:varchar(200)" json:"title"` + Description string `gorm:"type:text" json:"description"` + Tags string `gorm:"type:text" json:"tags"` + ImageURL string `gorm:"type:varchar(500);not null" json:"image_url"` + IsOnWall bool `gorm:"default:false" json:"is_on_wall"` + WallPosition *int `gorm:"type:int" json:"wall_position"` + Source string `gorm:"type:varchar(20);not null" json:"source"` + CreatedAt time.Time `gorm:"index" json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + User User `gorm:"foreignKey:UserID" json:"-"` +} + +func (p *Photo) BeforeCreate(tx *gorm.DB) error { + if p.ID == "" { + p.ID = uuid.New().String() + } + return nil +} diff --git a/backend/internal/model/task.go b/backend/internal/model/task.go new file mode 100644 index 00000000..b7d111d3 --- /dev/null +++ b/backend/internal/model/task.go @@ -0,0 +1,76 @@ +package model + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// TaskCategory enum +type TaskCategory string + +const ( + TaskCategoryHousework TaskCategory = "housework" + TaskCategoryParenting TaskCategory = "parenting" + TaskCategoryHealth TaskCategory = "health" + TaskCategoryEmotional TaskCategory = "emotional" +) + +// TaskStatus enum +type TaskStatus string + +const ( + TaskPending TaskStatus = "pending" + TaskCompleted TaskStatus = "completed" + TaskVerified TaskStatus = "verified" +) + +// DailyTask is a task template from which user tasks are generated. +type DailyTask struct { + ID string `gorm:"type:varchar(36);primaryKey" json:"id"` + Title string `gorm:"type:varchar(200);not null" json:"title"` + Description string `gorm:"type:text" json:"description"` + Category TaskCategory `gorm:"type:varchar(50);not null;index" json:"category"` + Difficulty int `gorm:"default:1" json:"difficulty"` // 1-5 + CreatedAt time.Time `json:"created_at"` +} + +func (t *DailyTask) BeforeCreate(tx *gorm.DB) error { + if t.ID == "" { + t.ID = uuid.New().String() + } + return nil +} + +// UserTask is a concrete task assigned to a user on a given date. +type UserTask struct { + ID string `gorm:"type:varchar(36);primaryKey" json:"id"` + UserID string `gorm:"type:varchar(36);not null;index" json:"user_id"` + TaskID string `gorm:"type:varchar(36);not null" json:"task_id"` + + Date time.Time `gorm:"type:date;not null;index" json:"date"` + Status TaskStatus `gorm:"type:varchar(20);default:'pending'" json:"status"` + + // Verification by partner + Score *int `gorm:"type:int" json:"score"` + Comment *string `gorm:"type:text" json:"comment"` + ScoredByID *string `gorm:"type:varchar(36)" json:"scored_by_id"` + ScoredAt *time.Time `json:"scored_at"` + + CompletedAt *time.Time `json:"completed_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + // Relationships + User User `gorm:"foreignKey:UserID" json:"user,omitempty"` + Task DailyTask `gorm:"foreignKey:TaskID" json:"task,omitempty"` + ScoredBy *User `gorm:"foreignKey:ScoredByID" json:"scored_by,omitempty"` +} + +func (ut *UserTask) BeforeCreate(tx *gorm.DB) error { + if ut.ID == "" { + ut.ID = uuid.New().String() + } + return nil +} diff --git a/backend/internal/model/whisper.go b/backend/internal/model/whisper.go new file mode 100644 index 00000000..4804ba9e --- /dev/null +++ b/backend/internal/model/whisper.go @@ -0,0 +1,25 @@ +package model + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type Whisper struct { + ID string `gorm:"type:varchar(36);primaryKey" json:"id"` + AuthorID string `gorm:"type:varchar(36);index;not null" json:"author_id"` + Content string `gorm:"type:text;not null" json:"content"` + CreatedAt time.Time `gorm:"index" json:"created_at"` + + // Relationships + Author User `gorm:"foreignKey:AuthorID" json:"author,omitempty"` +} + +func (w *Whisper) BeforeCreate(tx *gorm.DB) error { + if w.ID == "" { + w.ID = uuid.New().String() + } + return nil +} diff --git a/backend/internal/repository/answer.go b/backend/internal/repository/answer.go index 53553acd..a3066e48 100644 --- a/backend/internal/repository/answer.go +++ b/backend/internal/repository/answer.go @@ -86,6 +86,16 @@ func (r *AnswerRepo) Delete(id string) error { return r.db.Where("id = ?", id).Delete(&model.Answer{}).Error } +func (r *AnswerRepo) DeleteByQuestionID(questionID string) error { + return r.db.Where("question_id = ?", questionID).Delete(&model.Answer{}).Error +} + +func (r *AnswerRepo) FindIDsByQuestionID(questionID string) ([]string, error) { + var ids []string + err := r.db.Model(&model.Answer{}).Where("question_id = ?", questionID).Pluck("id", &ids).Error + return ids, err +} + func (r *AnswerRepo) UpdateLikeCount(id string, delta int) error { return r.db.Model(&model.Answer{}).Where("id = ?", id). UpdateColumn("like_count", gorm.Expr("like_count + ?", delta)).Error diff --git a/backend/internal/repository/comment.go b/backend/internal/repository/comment.go index e0c50ef4..75398228 100644 --- a/backend/internal/repository/comment.go +++ b/backend/internal/repository/comment.go @@ -52,6 +52,10 @@ func (r *CommentRepo) DeleteByParentID(parentID string) (int64, error) { return result.RowsAffected, result.Error } +func (r *CommentRepo) DeleteByAnswerID(answerID string) error { + return r.db.Where("answer_id = ?", answerID).Delete(&model.Comment{}).Error +} + func (r *CommentRepo) UpdateLikeCount(id string, delta int) error { return r.db.Model(&model.Comment{}).Where("id = ?", id). UpdateColumn("like_count", gorm.Expr("like_count + ?", delta)).Error diff --git a/backend/internal/repository/photo.go b/backend/internal/repository/photo.go new file mode 100644 index 00000000..ae1f622f --- /dev/null +++ b/backend/internal/repository/photo.go @@ -0,0 +1,103 @@ +package repository + +import ( + "github.com/momshell/backend/internal/model" + "gorm.io/gorm" +) + +type PhotoRepo struct { + db *gorm.DB +} + +func NewPhotoRepo(db *gorm.DB) *PhotoRepo { + return &PhotoRepo{db: db} +} + +func (r *PhotoRepo) FindByUserID(userID string, limit, offset int) ([]model.Photo, int64, error) { + query := r.db.Model(&model.Photo{}).Where("user_id = ?", userID) + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + var photos []model.Photo + err := query.Order("created_at desc").Offset(offset).Limit(limit).Find(&photos).Error + return photos, total, err +} + +func (r *PhotoRepo) FindWallPhotos(userID string) ([]model.Photo, error) { + var photos []model.Photo + err := r.db.Where("user_id = ? AND is_on_wall = ?", userID, true). + Order("wall_position asc").Find(&photos).Error + return photos, err +} + +func (r *PhotoRepo) FindByID(id string) (*model.Photo, error) { + var photo model.Photo + err := r.db.Where("id = ?", id).First(&photo).Error + if err != nil { + return nil, err + } + return &photo, nil +} + +func (r *PhotoRepo) FindByIDAndUserID(id, userID string) (*model.Photo, error) { + var photo model.Photo + err := r.db.Where("id = ? AND user_id = ?", id, userID).First(&photo).Error + if err != nil { + return nil, err + } + return &photo, nil +} + +func (r *PhotoRepo) CountByUserID(userID string) (int64, error) { + var count int64 + err := r.db.Model(&model.Photo{}).Where("user_id = ?", userID).Count(&count).Error + return count, err +} + +func (r *PhotoRepo) CountWallPhotos(userID string) (int64, error) { + var count int64 + err := r.db.Model(&model.Photo{}).Where("user_id = ? AND is_on_wall = ?", userID, true).Count(&count).Error + return count, err +} + +func (r *PhotoRepo) Create(photo *model.Photo) error { + return r.db.Create(photo).Error +} + +func (r *PhotoRepo) Update(photo *model.Photo) error { + return r.db.Save(photo).Error +} + +func (r *PhotoRepo) Delete(id, userID string) error { + return r.db.Where("id = ? AND user_id = ?", id, userID).Delete(&model.Photo{}).Error +} + +func (r *PhotoRepo) BatchUpdateWall(userID string, updates []WallUpdate) error { + return r.db.Transaction(func(tx *gorm.DB) error { + // Clear all wall positions for user first + if err := tx.Model(&model.Photo{}). + Where("user_id = ? AND is_on_wall = ?", userID, true). + Updates(map[string]interface{}{"is_on_wall": false, "wall_position": nil}).Error; err != nil { + return err + } + + // Set new wall positions + for _, u := range updates { + pos := u.Position + if err := tx.Model(&model.Photo{}). + Where("id = ? AND user_id = ?", u.PhotoID, userID). + Updates(map[string]interface{}{"is_on_wall": true, "wall_position": pos}).Error; err != nil { + return err + } + } + return nil + }) +} + +type WallUpdate struct { + PhotoID string + Position int +} diff --git a/backend/internal/repository/task.go b/backend/internal/repository/task.go new file mode 100644 index 00000000..e73d37fe --- /dev/null +++ b/backend/internal/repository/task.go @@ -0,0 +1,88 @@ +package repository + +import ( + "time" + + "github.com/momshell/backend/internal/model" + "gorm.io/gorm" +) + +type TaskRepo struct { + db *gorm.DB +} + +func NewTaskRepo(db *gorm.DB) *TaskRepo { + return &TaskRepo{db: db} +} + +// DailyTask template operations + +func (r *TaskRepo) CountTemplates() (int64, error) { + var count int64 + err := r.db.Model(&model.DailyTask{}).Count(&count).Error + return count, err +} + +func (r *TaskRepo) CreateTemplate(t *model.DailyTask) error { + return r.db.Create(t).Error +} + +func (r *TaskRepo) FindRandomTemplates(count int) ([]model.DailyTask, error) { + var tasks []model.DailyTask + err := r.db.Order("RANDOM()").Limit(count).Find(&tasks).Error + return tasks, err +} + +// UserTask operations + +func (r *TaskRepo) CreateUserTask(ut *model.UserTask) error { + return r.db.Create(ut).Error +} + +func (r *TaskRepo) FindUserTasksByDate(userID string, date time.Time) ([]model.UserTask, error) { + var tasks []model.UserTask + err := r.db.Preload("Task"). + Where("user_id = ? AND date = ?", userID, date.Format("2006-01-02")). + Order("created_at asc"). + Find(&tasks).Error + return tasks, err +} + +func (r *TaskRepo) FindUserTaskByID(id string) (*model.UserTask, error) { + var task model.UserTask + err := r.db.Preload("Task").Preload("User").First(&task, "id = ?", id).Error + if err != nil { + return nil, err + } + return &task, nil +} + +func (r *TaskRepo) UpdateUserTask(ut *model.UserTask) error { + return r.db.Save(ut).Error +} + +// ResetUserTaskToPending clears completed_at and sets status back to pending. +func (r *TaskRepo) ResetUserTaskToPending(ut *model.UserTask) error { + return r.db.Model(ut). + Select("status", "completed_at", "comment"). + Updates(map[string]interface{}{ + "status": ut.Status, + "completed_at": nil, + "comment": ut.Comment, + }).Error +} + +func (r *TaskRepo) SumScoreByUserID(userID string) (int, error) { + var total *int + err := r.db.Model(&model.UserTask{}). + Where("user_id = ? AND status = ? AND score IS NOT NULL", userID, model.TaskVerified). + Select("COALESCE(SUM(score), 0)"). + Scan(&total).Error + if err != nil { + return 0, err + } + if total == nil { + return 0, nil + } + return *total, nil +} diff --git a/backend/internal/repository/whisper.go b/backend/internal/repository/whisper.go new file mode 100644 index 00000000..40219eee --- /dev/null +++ b/backend/internal/repository/whisper.go @@ -0,0 +1,27 @@ +package repository + +import ( + "github.com/momshell/backend/internal/model" + "gorm.io/gorm" +) + +type WhisperRepo struct { + db *gorm.DB +} + +func NewWhisperRepo(db *gorm.DB) *WhisperRepo { + return &WhisperRepo{db: db} +} + +func (r *WhisperRepo) Create(w *model.Whisper) error { + return r.db.Create(w).Error +} + +func (r *WhisperRepo) FindByAuthorID(authorID string, limit int) ([]model.Whisper, error) { + var whispers []model.Whisper + err := r.db.Where("author_id = ?", authorID). + Order("created_at desc"). + Limit(limit). + Find(&whispers).Error + return whispers, err +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 07f068d3..58de2fb5 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -20,6 +20,9 @@ func Setup( echoHandler *handler.EchoHandler, userHandler *handler.UserHandler, adminHandler *handler.AdminHandler, + photoHandler *handler.PhotoHandler, + whisperHandler *handler.WhisperHandler, + taskHandler *handler.TaskHandler, ) { // Health check r.GET("/health", func(c *gin.Context) { @@ -146,6 +149,37 @@ func Setup( echo.POST("/memoirs/:id/rate", echoHandler.RateMemoir) } + // ==================== Photos ==================== + photos := api.Group("/photos", middleware.AuthRequired(cfg)) + { + photos.GET("", photoHandler.List) + photos.POST("/upload", photoHandler.Upload) + photos.POST("/generate", photoHandler.Generate) + photos.PUT("/wall", photoHandler.BatchUpdateWall) + photos.PUT("/:id", photoHandler.Update) + photos.DELETE("/:id", photoHandler.Delete) + photos.PUT("/:id/wall", photoHandler.ToggleWall) + } + + // ==================== Whisper (Heart Words) ==================== + whisper := api.Group("/whisper", middleware.AuthRequired(cfg)) + { + whisper.POST("", whisperHandler.Create) + whisper.GET("", whisperHandler.List) + whisper.GET("/tips", whisperHandler.Tips) + } + + // ==================== Tasks ==================== + tasks := api.Group("/tasks", middleware.AuthRequired(cfg)) + { + tasks.GET("/daily", taskHandler.DailyTasks) + tasks.POST("/:id/complete", taskHandler.Complete) + tasks.GET("/partner", taskHandler.PartnerTasks) + tasks.POST("/:id/score", taskHandler.Score) + tasks.POST("/:id/reject", taskHandler.Reject) + tasks.GET("/stats", taskHandler.Stats) + } + // ==================== Admin ==================== adminAPI := api.Group("/admin", middleware.AdminRequired(cfg)) { diff --git a/backend/internal/service/chat.go b/backend/internal/service/chat.go index 58402dac..94f99746 100644 --- a/backend/internal/service/chat.go +++ b/backend/internal/service/chat.go @@ -6,16 +6,18 @@ import ( "fmt" "log" "regexp" + "strings" "sync" "github.com/google/uuid" "github.com/momshell/backend/internal/dto" "github.com/momshell/backend/internal/model" "github.com/momshell/backend/internal/repository" + "github.com/momshell/backend/pkg/firecrawl" "github.com/momshell/backend/pkg/openai" ) -const companionSystemPrompt = `你是「贝壳姐姐」,一位「曾走过这段路的朋友」,专为产后恢复期女性设计的情感陪伴者。 +const companionSystemPrompt = `你是「小石光」,一位「曾走过这段路的朋友」,专为产后恢复期女性设计的情感陪伴者。 ## 角色定位:Independent Woman Supporter @@ -54,18 +56,20 @@ const companionSystemPrompt = `你是「贝壳姐姐」,一位「曾走过这 记住:你的存在不是为了「解决她的问题」,而是让她感到——在这一刻,她并不孤单。` type ChatService struct { - client *openai.Client - chatRepo *repository.ChatRepo + client *openai.Client + chatRepo *repository.ChatRepo + firecrawl *firecrawl.Client // In-memory storage for guest sessions mu sync.RWMutex guestMemory map[string][]map[string]interface{} guestProfiles map[string]map[string]interface{} } -func NewChatService(client *openai.Client, chatRepo *repository.ChatRepo) *ChatService { +func NewChatService(client *openai.Client, chatRepo *repository.ChatRepo, fc *firecrawl.Client) *ChatService { return &ChatService{ client: client, chatRepo: chatRepo, + firecrawl: fc, guestMemory: make(map[string][]map[string]interface{}), guestProfiles: make(map[string]map[string]interface{}), } @@ -87,6 +91,11 @@ func (s *ChatService) chatAuthenticated(ctx context.Context, msg dto.UserMessage formatTurns(turns), ) + webResults := s.searchWebForChat(ctx, msg.Content) + if webResults != "" { + systemPrompt += "\n\n## 联网搜索参考\n" + webResults + "\n如有引用搜索内容,请自然融入回答,标注来源。不确定的信息请标明。" + } + messages := []openai.Message{ {Role: "system", Content: systemPrompt}, {Role: "user", Content: msg.Content}, @@ -140,6 +149,11 @@ func (s *ChatService) chatGuest(ctx context.Context, msg dto.UserMessage) (*dto. formatTurns(turns), ) + webResults := s.searchWebForChat(ctx, msg.Content) + if webResults != "" { + systemPrompt += "\n\n## 联网搜索参考\n" + webResults + "\n如有引用搜索内容,请自然融入回答,标注来源。不确定的信息请标明。" + } + messages := []openai.Message{ {Role: "system", Content: systemPrompt}, {Role: "user", Content: msg.Content}, @@ -169,6 +183,32 @@ func (s *ChatService) chatGuest(ctx context.Context, msg dto.UserMessage) (*dto. return buildVisualResponse(parsed, memoryUpdated), nil } +func (s *ChatService) searchWebForChat(ctx context.Context, userMessage string) string { + if s.firecrawl == nil { + return "" + } + results, err := s.firecrawl.Search(ctx, userMessage, 3) + if err != nil { + log.Printf("[ChatService] web search failed: %v", err) + return "" + } + if len(results) == 0 { + return "" + } + var sb strings.Builder + for i, r := range results { + content := r.Markdown + if content == "" { + content = r.Description + } + if len([]rune(content)) > 300 { + content = string([]rune(content)[:300]) + "..." + } + fmt.Fprintf(&sb, "[来源%d] %s (%s): %s\n", i+1, r.Title, r.URL, content) + } + return sb.String() +} + func (s *ChatService) GetProfile(userID string) (*dto.ChatProfile, error) { mem, err := s.chatRepo.FindByUserID(userID) if err != nil { diff --git a/backend/internal/service/community.go b/backend/internal/service/community.go index 5528b6e1..b2a6b764 100644 --- a/backend/internal/service/community.go +++ b/backend/internal/service/community.go @@ -372,6 +372,12 @@ func (s *CommunityService) DeleteQuestion(questionID string, user *model.User) e } // Clean up related data + answerIDs, _ := s.answerRepo.FindIDsByQuestionID(questionID) + for _, aid := range answerIDs { + _ = s.commentRepo.DeleteByAnswerID(aid) + _ = s.interactionRepo.DeleteLikesByTarget("answer", aid) + } + _ = s.answerRepo.DeleteByQuestionID(questionID) _ = s.interactionRepo.DeleteLikesByTarget("question", questionID) _ = s.interactionRepo.DeleteCollectionsByQuestion(questionID) _ = s.tagRepo.DeleteQuestionTags(questionID) @@ -528,6 +534,7 @@ func (s *CommunityService) DeleteAnswer(answerID string, user *model.User) error return errors.New("无权删除此回答") } + _ = s.commentRepo.DeleteByAnswerID(answerID) _ = s.interactionRepo.DeleteLikesByTarget("answer", answerID) _ = s.questionRepo.DecrementAnswerCount(a.QuestionID) diff --git a/backend/internal/service/community_ai.go b/backend/internal/service/community_ai.go new file mode 100644 index 00000000..815f7f15 --- /dev/null +++ b/backend/internal/service/community_ai.go @@ -0,0 +1,374 @@ +package service + +import ( + "context" + "fmt" + "log" + "regexp" + "strings" + "time" + + "github.com/momshell/backend/internal/model" + "github.com/momshell/backend/internal/repository" + "github.com/momshell/backend/pkg/firecrawl" + "github.com/momshell/backend/pkg/openai" +) + +var mentionPattern = regexp.MustCompile(`@小石光`) + +type CommunityAIService struct { + client *openai.Client + firecrawl *firecrawl.Client + questionRepo *repository.QuestionRepo + answerRepo *repository.AnswerRepo + commentRepo *repository.CommentRepo + aiUserID string +} + +func NewCommunityAIService( + client *openai.Client, + fc *firecrawl.Client, + questionRepo *repository.QuestionRepo, + answerRepo *repository.AnswerRepo, + commentRepo *repository.CommentRepo, + aiUserID string, +) *CommunityAIService { + return &CommunityAIService{ + client: client, + firecrawl: fc, + questionRepo: questionRepo, + answerRepo: answerRepo, + commentRepo: commentRepo, + aiUserID: aiUserID, + } +} + +// ContainsMention checks if content mentions @小石光. +func ContainsMention(content string) bool { + return mentionPattern.MatchString(content) +} + +// ShouldReplyToComment returns true if AI should reply to this comment: +// - comment mentions @小石光, OR +// - comment is on an AI-authored answer, OR +// - comment is a reply to an AI-authored comment. +func (s *CommunityAIService) ShouldReplyToComment(content, answerID string, parentID *string) bool { + if ContainsMention(content) { + return true + } + answer, err := s.answerRepo.FindByID(answerID) + if err == nil && answer.AuthorID == s.aiUserID { + return true + } + if parentID != nil { + parent, err := s.commentRepo.FindByID(*parentID) + if err == nil && parent.AuthorID == s.aiUserID { + return true + } + } + return false +} + +// HandleNewQuestion generates an AI answer for a question that mentions @小石光. +func (s *CommunityAIService) HandleNewQuestion(questionID string) { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + q, err := s.questionRepo.FindByID(questionID) + if err != nil { + log.Printf("[CommunityAI] failed to find question %s: %v", questionID, err) + return + } + + threadCtx := fmt.Sprintf("帖子标题:%s\n帖子内容:%s", q.Title, q.Content) + searchCtx, sources := s.searchWeb(ctx, q.Title) + + reply, err := s.generateReply(ctx, threadCtx, searchCtx, sources) + if err != nil { + log.Printf("[CommunityAI] failed to generate reply for question %s: %v", questionID, err) + return + } + + answer := &model.Answer{ + QuestionID: questionID, + AuthorID: s.aiUserID, + Content: reply, + AuthorRole: model.RoleAIAssistant, + IsProfessional: false, + Status: model.StatusPublished, + } + + if err := s.answerRepo.Create(answer); err != nil { + log.Printf("[CommunityAI] failed to create answer for question %s: %v", questionID, err) + return + } + + _ = s.questionRepo.IncrementAnswerCount(questionID) + log.Printf("[CommunityAI] AI answered question %s", questionID) +} + +// HandleNewAnswer generates an AI comment on an answer that mentions @小石光. +func (s *CommunityAIService) HandleNewAnswer(questionID, answerID string) { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + q, err := s.questionRepo.FindByID(questionID) + if err != nil { + log.Printf("[CommunityAI] failed to find question %s: %v", questionID, err) + return + } + + answer, err := s.answerRepo.FindByID(answerID) + if err != nil { + log.Printf("[CommunityAI] failed to find answer %s: %v", answerID, err) + return + } + + var sb strings.Builder + fmt.Fprintf(&sb, "帖子标题:%s\n帖子内容:%s\n\n", q.Title, q.Content) + fmt.Fprintf(&sb, "用户回答:%s", answer.Content) + + searchCtx, sources := s.searchWeb(ctx, q.Title) + + reply, err := s.generateReply(ctx, sb.String(), searchCtx, sources) + if err != nil { + log.Printf("[CommunityAI] failed to generate reply for answer %s: %v", answerID, err) + return + } + + comment := &model.Comment{ + AnswerID: answerID, + AuthorID: s.aiUserID, + ReplyToUserID: &answer.AuthorID, + Content: reply, + Status: model.StatusPublished, + } + + if err := s.commentRepo.Create(comment); err != nil { + log.Printf("[CommunityAI] failed to create comment on answer %s: %v", answerID, err) + return + } + + _ = s.answerRepo.IncrementCommentCount(answerID) + log.Printf("[CommunityAI] AI commented on answer %s", answerID) +} + +// HandleNewComment generates an AI comment reply when a comment mentions @小石光. +func (s *CommunityAIService) HandleNewComment(answerID, commentID string) { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + answer, err := s.answerRepo.FindByID(answerID) + if err != nil { + log.Printf("[CommunityAI] failed to find answer %s: %v", answerID, err) + return + } + + q, err := s.questionRepo.FindByID(answer.QuestionID) + if err != nil { + log.Printf("[CommunityAI] failed to find question %s: %v", answer.QuestionID, err) + return + } + + comments, err := s.commentRepo.FindByAnswerID(answerID) + if err != nil { + log.Printf("[CommunityAI] failed to find comments for answer %s: %v", answerID, err) + return + } + + var sb strings.Builder + fmt.Fprintf(&sb, "帖子标题:%s\n帖子内容:%s\n\n", q.Title, q.Content) + fmt.Fprintf(&sb, "当前回答:%s\n\n评论区对话:\n", answer.Content) + for _, c := range comments { + role := "用户" + if c.AuthorID == s.aiUserID { + role = "小石光" + } + fmt.Fprintf(&sb, "%s:%s\n", role, c.Content) + } + + searchCtx, sources := s.searchWeb(ctx, q.Title) + + reply, err := s.generateReply(ctx, sb.String(), searchCtx, sources) + if err != nil { + log.Printf("[CommunityAI] failed to generate comment reply: %v", err) + return + } + + triggerComment, err := s.commentRepo.FindByID(commentID) + if err != nil { + log.Printf("[CommunityAI] failed to find trigger comment %s: %v", commentID, err) + return + } + + comment := &model.Comment{ + AnswerID: answerID, + AuthorID: s.aiUserID, + ParentID: &commentID, + ReplyToUserID: &triggerComment.AuthorID, + Content: reply, + Status: model.StatusPublished, + } + + if err := s.commentRepo.Create(comment); err != nil { + log.Printf("[CommunityAI] failed to create comment reply: %v", err) + return + } + + _ = s.answerRepo.IncrementCommentCount(answerID) + log.Printf("[CommunityAI] AI replied to comment %s on answer %s", commentID, answerID) +} + +type sourceRef struct { + index int + title string + url string +} + +func (s *CommunityAIService) searchWeb(ctx context.Context, query string) (string, []sourceRef) { + if s.firecrawl == nil { + return "", nil + } + + results, err := s.firecrawl.Search(ctx, query, 3) + if err != nil { + log.Printf("[CommunityAI] web search failed: %v", err) + return "", nil + } + if len(results) == 0 { + return "", nil + } + + var sources []sourceRef + var sb strings.Builder + for i, r := range results { + content := r.Markdown + if content == "" { + content = r.Description + } + if len([]rune(content)) > 500 { + content = string([]rune(content)[:500]) + "..." + } + fmt.Fprintf(&sb, "[来源%d] %s\n链接:%s\n内容:%s\n\n", i+1, r.Title, r.URL, content) + sources = append(sources, sourceRef{index: i + 1, title: r.Title, url: r.URL}) + } + return sb.String(), sources +} + +const communityAISystemPrompt = `你是「小石光」,一位温柔且专业的产后恢复陪伴者,正在社区帖子中回复用户的提问。 + +## 角色定位 +你面对的每一位用户,首先是一个完整的「独立女性」,其次才是新手妈妈。你用温暖、真诚、专业的语气回应她们的问题和困惑。 + +## 回复原则 +1. **认可与共情优先**:先看见和认可她的感受,再提供建议 +2. **拒绝说教**:用「我了解到...」「有研究表明...」「或许可以试试...」的方式分享 +3. **保护主体性**:提醒她有权利为自己发声,可以寻求帮助 +4. **避免有毒正能量**:承认困难的真实性 + +## 帖子上下文 +%s + +## 联网搜索结果 +%s + +## 防幻觉规则(严格遵守) +1. 只基于上述帖子上下文和搜索结果回答 +2. 仅在提供事实性信息时引用来源(如医学知识、研究数据、具体机构推荐),日常共情和鼓励不需要引用 +3. 如果不确定,明确说「关于这一点我不太确定,建议咨询专业医生/心理咨询师」 +4. 绝不编造医疗数据、药物剂量、具体治疗方案 +5. 涉及医疗问题时,始终建议咨询专业医生 + +## 回复要求 +- 用纯文本回复,不要使用 JSON 格式 +- 语气温暖自然,像朋友聊天 +- 1-3段话即可,不要过长 +- 不要重复用户说过的话 +- 移除@提及,直接回复内容` + +func (s *CommunityAIService) generateReply(ctx context.Context, threadContext, searchContext string, sources []sourceRef) (string, error) { + if searchContext == "" { + searchContext = "(无搜索结果)" + } + + systemPrompt := fmt.Sprintf(communityAISystemPrompt, threadContext, searchContext) + + messages := []openai.Message{ + {Role: "system", Content: systemPrompt}, + {Role: "user", Content: "请根据帖子上下文,给出一个温暖、有帮助的回复。"}, + } + + reply, err := s.client.Chat(ctx, messages) + if err != nil { + return "", fmt.Errorf("AI 服务调用失败: %w", err) + } + + reply = mentionPattern.ReplaceAllString(reply, "") + reply = strings.TrimSpace(reply) + + if reply == "" { + reply = "谢谢你的分享。如果需要更多帮助,随时可以找我聊聊。" + } + + // Append footnotes for any cited sources + reply = appendSourceFootnotes(reply, sources) + + return reply, nil +} + +var sourceRefPattern = regexp.MustCompile(`\[来源(\d+)\]`) + +func appendSourceFootnotes(reply string, sources []sourceRef) string { + matches := sourceRefPattern.FindAllStringSubmatch(reply, -1) + if len(matches) == 0 || len(sources) == 0 { + return reply + } + + // Collect cited indices in order of first appearance + seen := make(map[int]bool) + var citedOrder []int + for _, m := range matches { + var idx int + if _, err := fmt.Sscanf(m[1], "%d", &idx); err == nil && !seen[idx] { + seen[idx] = true + citedOrder = append(citedOrder, idx) + } + } + + // Build renumber map: old index -> new sequential index + renumber := make(map[int]int) + for newIdx, oldIdx := range citedOrder { + renumber[oldIdx] = newIdx + 1 + } + + // Replace [来源N] with renumbered [来源M] in reply text + replaced := sourceRefPattern.ReplaceAllStringFunc(reply, func(match string) string { + sub := sourceRefPattern.FindStringSubmatch(match) + var oldIdx int + if _, err := fmt.Sscanf(sub[1], "%d", &oldIdx); err == nil { + if newIdx, ok := renumber[oldIdx]; ok { + return fmt.Sprintf("[来源%d]", newIdx) + } + } + return match + }) + + // Build source index map for quick lookup + sourceMap := make(map[int]sourceRef) + for _, src := range sources { + sourceMap[src.index] = src + } + + // Append footnotes with renumbered indices + var footnotes strings.Builder + for _, oldIdx := range citedOrder { + if src, ok := sourceMap[oldIdx]; ok { + fmt.Fprintf(&footnotes, "\n[来源%d] %s %s", renumber[oldIdx], src.title, src.url) + } + } + + if footnotes.Len() > 0 { + replaced += "\n" + footnotes.String() + } + return replaced +} diff --git a/backend/internal/service/echo.go b/backend/internal/service/echo.go index 33e9d0f7..074ab35e 100644 --- a/backend/internal/service/echo.go +++ b/backend/internal/service/echo.go @@ -217,28 +217,117 @@ func buildMemoirSystemPrompt(tags []model.IdentityTag, theme *string) string { func parseMemoirLLMResponse(content string) map[string]interface{} { var result map[string]interface{} + // Strip Qwen3's ... blocks + thinkRe := regexp.MustCompile(`(?s).*?`) + content = strings.TrimSpace(thinkRe.ReplaceAllString(content, "")) + + // Try direct JSON parse if err := json.Unmarshal([]byte(content), &result); err == nil { - return result + return cleanParsedMemoir(result) } + // Try extracting from ```json ... ``` code block re := regexp.MustCompile("(?s)```json\\s*(.*?)\\s*```") if matches := re.FindStringSubmatch(content); len(matches) > 1 { if err := json.Unmarshal([]byte(matches[1]), &result); err == nil { - return result + return cleanParsedMemoir(result) } } + // Try extracting any JSON object (greedy) re2 := regexp.MustCompile(`(?s)\{.*\}`) if match := re2.FindString(content); match != "" { if err := json.Unmarshal([]byte(match), &result); err == nil { - return result + return cleanParsedMemoir(result) + } + } + + // Handle truncated ```json block (no closing ```) - extract everything after ```json + re3 := regexp.MustCompile("(?s)```json\\s*(.*)") + if matches := re3.FindStringSubmatch(content); len(matches) > 1 { + inner := strings.TrimSpace(matches[1]) + inner = strings.TrimSuffix(inner, "```") + if err := json.Unmarshal([]byte(inner), &result); err == nil { + return cleanParsedMemoir(result) + } + // Try to find JSON object inside + if match := re2.FindString(inner); match != "" { + if err := json.Unmarshal([]byte(match), &result); err == nil { + return cleanParsedMemoir(result) + } + } + } + + // Last resort: try to extract title and content with regex from malformed JSON + titleRe := regexp.MustCompile(`"title"\s*:\s*"((?:[^"\\]|\\.)*)`) + contentRe := regexp.MustCompile(`"content"\s*:\s*"((?:[^"\\]|\\.)*)`) + titleMatch := titleRe.FindStringSubmatch(content) + contentMatch := contentRe.FindStringSubmatch(content) + if titleMatch != nil || contentMatch != nil { + title := "一段温柔的回响" + body := "" + if titleMatch != nil { + title = titleMatch[1] + } + if contentMatch != nil { + body = contentMatch[1] + // Unescape common JSON escapes + body = strings.ReplaceAll(body, `\n`, "\n") + body = strings.ReplaceAll(body, `\"`, `"`) + body = strings.ReplaceAll(body, `\\`, `\`) + } + return map[string]interface{}{ + "title": cleanMemoirText(title), + "content": cleanMemoirText(body), } } return map[string]interface{}{ "title": "一段温柔的回响", - "content": strings.TrimSpace(content), + "content": cleanMemoirText(strings.TrimSpace(content)), + } +} + +// cleanMemoirText strips leftover JSON/markdown artifacts from LLM output. +func cleanMemoirText(s string) string { + s = strings.TrimSpace(s) + // Remove trailing markdown code fences and JSON braces + s = strings.TrimRight(s, " \t\n\r") + for { + trimmed := s + trimmed = strings.TrimSuffix(trimmed, "```") + trimmed = strings.TrimSuffix(trimmed, "}") + trimmed = strings.TrimSuffix(trimmed, `"`) + trimmed = strings.TrimRight(trimmed, " \t\n\r") + if trimmed == s { + break + } + s = trimmed + } + // Remove leading markdown code fences + for { + trimmed := s + trimmed = strings.TrimPrefix(trimmed, "```json") + trimmed = strings.TrimPrefix(trimmed, "```") + trimmed = strings.TrimPrefix(trimmed, "{") + trimmed = strings.TrimLeft(trimmed, " \t\n\r") + if trimmed == s { + break + } + s = trimmed + } + return strings.TrimSpace(s) +} + +// cleanParsedMemoir cleans title and content fields in a successfully parsed JSON map. +func cleanParsedMemoir(m map[string]interface{}) map[string]interface{} { + if t, ok := m["title"].(string); ok { + m["title"] = cleanMemoirText(t) + } + if c, ok := m["content"].(string); ok { + m["content"] = cleanMemoirText(c) } + return m } func generateMemoirCoverDataURI(title string, theme *string) string { diff --git a/backend/internal/service/photo.go b/backend/internal/service/photo.go new file mode 100644 index 00000000..f3b995e1 --- /dev/null +++ b/backend/internal/service/photo.go @@ -0,0 +1,335 @@ +package service + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "math" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/google/uuid" + "github.com/momshell/backend/internal/dto" + "github.com/momshell/backend/internal/model" + "github.com/momshell/backend/internal/repository" + "github.com/momshell/backend/pkg/openai" +) + +const ( + maxPhotosPerUser = 50 + maxWallPhotos = 9 +) + +type PhotoService struct { + photoRepo *repository.PhotoRepo + aiClient *openai.Client + imageModel string +} + +func NewPhotoService(photoRepo *repository.PhotoRepo, aiClient *openai.Client, imageModel string) *PhotoService { + return &PhotoService{ + photoRepo: photoRepo, + aiClient: aiClient, + imageModel: imageModel, + } +} + +func (s *PhotoService) ListPhotos(userID string, page, pageSize int) (*dto.PhotoListResponse, error) { + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 50 { + pageSize = 20 + } + offset := (page - 1) * pageSize + + photos, total, err := s.photoRepo.FindByUserID(userID, pageSize, offset) + if err != nil { + return nil, fmt.Errorf("failed to list photos: %w", err) + } + + totalPages := int(math.Ceil(float64(total) / float64(pageSize))) + + items := make([]dto.PhotoResponse, 0, len(photos)) + for _, p := range photos { + items = append(items, toPhotoResponse(p)) + } + + return &dto.PhotoListResponse{ + Photos: items, + Total: total, + Page: page, + PageSize: pageSize, + TotalPages: totalPages, + }, nil +} + +func (s *PhotoService) GetPhoto(id, userID string) (*dto.PhotoResponse, error) { + photo, err := s.photoRepo.FindByIDAndUserID(id, userID) + if err != nil { + return nil, fmt.Errorf("photo not found") + } + resp := toPhotoResponse(*photo) + return &resp, nil +} + +func (s *PhotoService) CreateFromUpload(userID, title, imageURL string) (*dto.PhotoResponse, error) { + count, err := s.photoRepo.CountByUserID(userID) + if err != nil { + return nil, fmt.Errorf("failed to check photo count: %w", err) + } + if count >= maxPhotosPerUser { + return nil, fmt.Errorf("photo limit reached (max %d)", maxPhotosPerUser) + } + + photo := &model.Photo{ + UserID: userID, + Title: title, + ImageURL: imageURL, + Source: "upload", + } + + if err := s.photoRepo.Create(photo); err != nil { + return nil, fmt.Errorf("failed to create photo: %w", err) + } + + resp := toPhotoResponse(*photo) + return &resp, nil +} + +func (s *PhotoService) GeneratePhoto(ctx context.Context, userID string, req dto.GeneratePhotoRequest) (*dto.PhotoResponse, error) { + if s.imageModel == "" { + return nil, fmt.Errorf("image generation is not configured") + } + + count, err := s.photoRepo.CountByUserID(userID) + if err != nil { + return nil, fmt.Errorf("failed to check photo count: %w", err) + } + if count >= maxPhotosPerUser { + return nil, fmt.Errorf("photo limit reached (max %d)", maxPhotosPerUser) + } + + imgResp, err := s.aiClient.GenerateImage(ctx, s.imageModel, req.Prompt) + if err != nil { + return nil, fmt.Errorf("image generation failed: %w", err) + } + + imageURL, err := s.downloadGeneratedImage(imgResp) + if err != nil { + return nil, fmt.Errorf("failed to save generated image: %w", err) + } + + photo := &model.Photo{ + UserID: userID, + Title: truncate(req.Prompt, 200), + Description: req.Prompt, + ImageURL: imageURL, + Source: "ai_generated", + } + + if err := s.photoRepo.Create(photo); err != nil { + return nil, fmt.Errorf("failed to create photo: %w", err) + } + + resp := toPhotoResponse(*photo) + return &resp, nil +} + +func (s *PhotoService) UpdatePhoto(id, userID string, req dto.UpdatePhotoRequest) (*dto.PhotoResponse, error) { + photo, err := s.photoRepo.FindByIDAndUserID(id, userID) + if err != nil { + return nil, fmt.Errorf("photo not found") + } + + if req.Title != nil { + photo.Title = *req.Title + } + if req.Description != nil { + photo.Description = *req.Description + } + if req.Tags != nil { + tagsJSON, jsonErr := json.Marshal(req.Tags) + if jsonErr != nil { + return nil, fmt.Errorf("failed to encode tags: %w", jsonErr) + } + photo.Tags = string(tagsJSON) + } + + if err := s.photoRepo.Update(photo); err != nil { + return nil, fmt.Errorf("failed to update photo: %w", err) + } + + resp := toPhotoResponse(*photo) + return &resp, nil +} + +func (s *PhotoService) DeletePhoto(id, userID string) error { + photo, err := s.photoRepo.FindByIDAndUserID(id, userID) + if err != nil { + return fmt.Errorf("photo not found") + } + + // Remove file from disk if it's a local upload + if photo.ImageURL != "" { + localPath := "." + photo.ImageURL + _ = os.Remove(localPath) + } + + return s.photoRepo.Delete(id, userID) +} + +func (s *PhotoService) ToggleWall(id, userID string, req dto.ToggleWallRequest) (*dto.PhotoResponse, error) { + photo, err := s.photoRepo.FindByIDAndUserID(id, userID) + if err != nil { + return nil, fmt.Errorf("photo not found") + } + + if req.IsOnWall && !photo.IsOnWall { + wallCount, countErr := s.photoRepo.CountWallPhotos(userID) + if countErr != nil { + return nil, fmt.Errorf("failed to check wall count: %w", countErr) + } + if wallCount >= maxWallPhotos { + return nil, fmt.Errorf("wall is full (max %d photos)", maxWallPhotos) + } + } + + photo.IsOnWall = req.IsOnWall + photo.WallPosition = req.WallPosition + if !req.IsOnWall { + photo.WallPosition = nil + } + + if err := s.photoRepo.Update(photo); err != nil { + return nil, fmt.Errorf("failed to update photo: %w", err) + } + + resp := toPhotoResponse(*photo) + return &resp, nil +} + +func (s *PhotoService) BatchUpdateWall(userID string, req dto.BatchWallUpdateRequest) ([]dto.PhotoResponse, error) { + if len(req.Photos) > maxWallPhotos { + return nil, fmt.Errorf("too many wall photos (max %d)", maxWallPhotos) + } + + updates := make([]repository.WallUpdate, 0, len(req.Photos)) + for _, item := range req.Photos { + updates = append(updates, repository.WallUpdate{ + PhotoID: item.PhotoID, + Position: item.Position, + }) + } + + if err := s.photoRepo.BatchUpdateWall(userID, updates); err != nil { + return nil, fmt.Errorf("failed to update wall: %w", err) + } + + wallPhotos, err := s.photoRepo.FindWallPhotos(userID) + if err != nil { + return nil, fmt.Errorf("failed to fetch wall photos: %w", err) + } + + results := make([]dto.PhotoResponse, 0, len(wallPhotos)) + for _, p := range wallPhotos { + results = append(results, toPhotoResponse(p)) + } + return results, nil +} + +func (s *PhotoService) downloadGeneratedImage(imgResp *openai.ImageResponse) (string, error) { + uploadDir := "uploads/photos" + if err := os.MkdirAll(uploadDir, 0o755); err != nil { + return "", fmt.Errorf("failed to create upload dir: %w", err) + } + + filename := fmt.Sprintf("%s_%d.png", uuid.New().String(), time.Now().Unix()) + savePath := filepath.Join(uploadDir, filename) + + data := imgResp.Data[0] + + if data.URL != "" { + return s.downloadFromURL(data.URL, savePath) + } + + if data.B64JSON != "" { + decoded, err := base64.StdEncoding.DecodeString(data.B64JSON) + if err != nil { + return "", fmt.Errorf("failed to decode base64 image: %w", err) + } + if err := os.WriteFile(savePath, decoded, 0o644); err != nil { + return "", fmt.Errorf("failed to write image file: %w", err) + } + return "/uploads/photos/" + filename, nil + } + + return "", fmt.Errorf("no image data in response") +} + +func (s *PhotoService) downloadFromURL(imageURL, savePath string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, "GET", imageURL, nil) + if err != nil { + return "", fmt.Errorf("failed to create download request: %w", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("failed to download image: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("download failed with status: %d", resp.StatusCode) + } + + out, err := os.Create(savePath) + if err != nil { + return "", fmt.Errorf("failed to create file: %w", err) + } + defer func() { _ = out.Close() }() + + if _, err := io.Copy(out, resp.Body); err != nil { + return "", fmt.Errorf("failed to write file: %w", err) + } + + return "/uploads/photos/" + filepath.Base(savePath), nil +} + +func toPhotoResponse(p model.Photo) dto.PhotoResponse { + var tags []string + if p.Tags != "" { + _ = json.Unmarshal([]byte(p.Tags), &tags) + } + if tags == nil { + tags = []string{} + } + + return dto.PhotoResponse{ + ID: p.ID, + Title: p.Title, + Description: p.Description, + Tags: tags, + ImageURL: p.ImageURL, + IsOnWall: p.IsOnWall, + WallPosition: p.WallPosition, + Source: p.Source, + CreatedAt: p.CreatedAt.Format(time.RFC3339), + UpdatedAt: p.UpdatedAt.Format(time.RFC3339), + } +} + +func truncate(s string, maxLen int) string { + runes := []rune(s) + if len(runes) > maxLen { + return string(runes[:maxLen]) + } + return s +} diff --git a/backend/internal/service/task.go b/backend/internal/service/task.go new file mode 100644 index 00000000..46c84273 --- /dev/null +++ b/backend/internal/service/task.go @@ -0,0 +1,270 @@ +package service + +import ( + "fmt" + "math" + "time" + + "github.com/momshell/backend/internal/dto" + "github.com/momshell/backend/internal/model" + "github.com/momshell/backend/internal/repository" +) + +type TaskService struct { + taskRepo *repository.TaskRepo + userRepo *repository.UserRepo +} + +func NewTaskService(taskRepo *repository.TaskRepo, userRepo *repository.UserRepo) *TaskService { + return &TaskService{taskRepo: taskRepo, userRepo: userRepo} +} + +func today() time.Time { + now := time.Now() + return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) +} + +// GetDailyTasks returns today's tasks for a Dad user, lazily creating them if needed. +func (s *TaskService) GetDailyTasks(userID string) ([]dto.UserTaskItem, error) { + user, err := s.userRepo.FindByID(userID) + if err != nil { + return nil, fmt.Errorf("用户不存在") + } + if user.Role != model.RoleDad { + return nil, fmt.Errorf("只有爸爸角色可以查看任务") + } + if user.PartnerID == nil { + return nil, fmt.Errorf("请先完成伴侣绑定") + } + + date := today() + tasks, err := s.taskRepo.FindUserTasksByDate(userID, date) + if err != nil { + return nil, err + } + + // Lazy initialization: assign tasks for today + if len(tasks) == 0 { + templates, err := s.taskRepo.FindRandomTemplates(4) + if err != nil || len(templates) == 0 { + return []dto.UserTaskItem{}, nil + } + for _, tmpl := range templates { + ut := &model.UserTask{ + UserID: userID, + TaskID: tmpl.ID, + Date: date, + Status: model.TaskPending, + } + if err := s.taskRepo.CreateUserTask(ut); err != nil { + continue + } + } + tasks, err = s.taskRepo.FindUserTasksByDate(userID, date) + if err != nil { + return nil, err + } + } + + return toTaskItems(tasks), nil +} + +// CompleteTask marks a task as completed by the Dad user. +func (s *TaskService) CompleteTask(userID, taskID string) (*dto.UserTaskItem, error) { + ut, err := s.taskRepo.FindUserTaskByID(taskID) + if err != nil { + return nil, fmt.Errorf("任务不存在") + } + if ut.UserID != userID { + return nil, fmt.Errorf("无权操作此任务") + } + if ut.Status != model.TaskPending { + return nil, fmt.Errorf("任务已完成或已验收") + } + + now := time.Now() + ut.Status = model.TaskCompleted + ut.CompletedAt = &now + + if err := s.taskRepo.UpdateUserTask(ut); err != nil { + return nil, err + } + + item := toTaskItem(*ut) + return &item, nil +} + +// GetPartnerTasks returns the partner (Dad)'s tasks for Mom to review. +func (s *TaskService) GetPartnerTasks(callerID string) ([]dto.UserTaskItem, error) { + user, err := s.userRepo.FindByID(callerID) + if err != nil { + return nil, fmt.Errorf("用户不存在") + } + if user.Role != model.RoleMom { + return nil, fmt.Errorf("只有妈妈角色可以查看伴侣任务") + } + if user.PartnerID == nil { + return nil, fmt.Errorf("请先完成伴侣绑定") + } + + date := today() + tasks, err := s.taskRepo.FindUserTasksByDate(*user.PartnerID, date) + if err != nil { + return nil, err + } + + // Lazily assign tasks for partner if none exist today + if len(tasks) == 0 { + templates, err := s.taskRepo.FindRandomTemplates(4) + if err != nil || len(templates) == 0 { + return []dto.UserTaskItem{}, nil + } + for _, tmpl := range templates { + ut := &model.UserTask{ + UserID: *user.PartnerID, + TaskID: tmpl.ID, + Date: date, + Status: model.TaskPending, + } + if err := s.taskRepo.CreateUserTask(ut); err != nil { + continue + } + } + tasks, err = s.taskRepo.FindUserTasksByDate(*user.PartnerID, date) + if err != nil { + return nil, err + } + } + + return toTaskItems(tasks), nil +} + +// ScoreTask allows Mom to verify and score a completed task. +func (s *TaskService) ScoreTask(callerID, taskID string, req dto.TaskScore) (*dto.UserTaskItem, error) { + caller, err := s.userRepo.FindByID(callerID) + if err != nil { + return nil, fmt.Errorf("用户不存在") + } + if caller.Role != model.RoleMom { + return nil, fmt.Errorf("只有妈妈角色可以验收任务") + } + if caller.PartnerID == nil { + return nil, fmt.Errorf("请先完成伴侣绑定") + } + + ut, err := s.taskRepo.FindUserTaskByID(taskID) + if err != nil { + return nil, fmt.Errorf("任务不存在") + } + if ut.UserID != *caller.PartnerID { + return nil, fmt.Errorf("只能验收伴侣的任务") + } + if ut.Status != model.TaskCompleted { + return nil, fmt.Errorf("只能验收已完成的任务") + } + + now := time.Now() + ut.Status = model.TaskVerified + ut.Score = &req.Score + if req.Comment != "" { + ut.Comment = &req.Comment + } + ut.ScoredByID = &callerID + ut.ScoredAt = &now + + if err := s.taskRepo.UpdateUserTask(ut); err != nil { + return nil, err + } + + item := toTaskItem(*ut) + return &item, nil +} + +// RejectTask allows Mom to reject a completed task back to pending. +func (s *TaskService) RejectTask(callerID, taskID string, comment string) (*dto.UserTaskItem, error) { + caller, err := s.userRepo.FindByID(callerID) + if err != nil { + return nil, fmt.Errorf("用户不存在") + } + if caller.Role != model.RoleMom { + return nil, fmt.Errorf("只有妈妈角色可以验收任务") + } + if caller.PartnerID == nil { + return nil, fmt.Errorf("请先完成伴侣绑定") + } + + ut, err := s.taskRepo.FindUserTaskByID(taskID) + if err != nil { + return nil, fmt.Errorf("任务不存在") + } + if ut.UserID != *caller.PartnerID { + return nil, fmt.Errorf("只能验收伴侣的任务") + } + if ut.Status != model.TaskCompleted { + return nil, fmt.Errorf("只能驳回已完成的任务") + } + + ut.Status = model.TaskPending + ut.CompletedAt = nil + if comment != "" { + ut.Comment = &comment + } + + if err := s.taskRepo.ResetUserTaskToPending(ut); err != nil { + return nil, err + } + + item := toTaskItem(*ut) + return &item, nil +} + +// GetTaskStats returns XP and level for a user (Dad). +func (s *TaskService) GetTaskStats(userID string) (*dto.TaskStats, error) { + user, err := s.userRepo.FindByID(userID) + if err != nil { + return nil, fmt.Errorf("用户不存在") + } + if user.PartnerID == nil { + return &dto.TaskStats{XP: 0, Level: 1}, nil + } + + // For Mom, show partner's stats + targetID := userID + if user.Role == model.RoleMom { + targetID = *user.PartnerID + } + + xp, err := s.taskRepo.SumScoreByUserID(targetID) + if err != nil { + return nil, err + } + + // Level formula: level = floor(sqrt(xp / 10)) + 1 + level := int(math.Floor(math.Sqrt(float64(xp)/10))) + 1 + + return &dto.TaskStats{XP: xp, Level: level}, nil +} + +func toTaskItem(ut model.UserTask) dto.UserTaskItem { + return dto.UserTaskItem{ + ID: ut.ID, + Title: ut.Task.Title, + Description: ut.Task.Description, + Category: string(ut.Task.Category), + Difficulty: ut.Task.Difficulty, + Status: string(ut.Status), + Score: ut.Score, + Comment: ut.Comment, + CompletedAt: ut.CompletedAt, + ScoredAt: ut.ScoredAt, + Date: ut.Date, + } +} + +func toTaskItems(tasks []model.UserTask) []dto.UserTaskItem { + items := make([]dto.UserTaskItem, len(tasks)) + for i, t := range tasks { + items[i] = toTaskItem(t) + } + return items +} diff --git a/backend/internal/service/whisper.go b/backend/internal/service/whisper.go new file mode 100644 index 00000000..a055bd87 --- /dev/null +++ b/backend/internal/service/whisper.go @@ -0,0 +1,170 @@ +package service + +import ( + "context" + "fmt" + "log" + "strings" + "time" + + "github.com/momshell/backend/internal/dto" + "github.com/momshell/backend/internal/model" + "github.com/momshell/backend/internal/repository" + "github.com/momshell/backend/pkg/openai" +) + +type WhisperService struct { + whisperRepo *repository.WhisperRepo + userRepo *repository.UserRepo + aiClient *openai.Client +} + +func NewWhisperService( + whisperRepo *repository.WhisperRepo, + userRepo *repository.UserRepo, + aiClient *openai.Client, +) *WhisperService { + return &WhisperService{ + whisperRepo: whisperRepo, + userRepo: userRepo, + aiClient: aiClient, + } +} + +// CreateWhisper creates a new whisper from a mom user. +func (s *WhisperService) CreateWhisper(authorID, content string) (*dto.WhisperItem, error) { + user, err := s.userRepo.FindByID(authorID) + if err != nil { + return nil, fmt.Errorf("用户不存在") + } + if user.Role != model.RoleMom { + return nil, fmt.Errorf("只有妈妈角色可以写心语") + } + if user.PartnerID == nil { + return nil, fmt.Errorf("请先完成伴侣绑定") + } + + w := &model.Whisper{ + AuthorID: authorID, + Content: content, + } + if err := s.whisperRepo.Create(w); err != nil { + return nil, err + } + + return &dto.WhisperItem{ + ID: w.ID, + Content: w.Content, + CreatedAt: w.CreatedAt, + }, nil +} + +// GetWhispers returns whispers for the partner to read. +// If the caller is Dad, returns Mom's whispers. +func (s *WhisperService) GetWhispers(callerID string) ([]dto.WhisperItem, error) { + user, err := s.userRepo.FindByID(callerID) + if err != nil { + return nil, fmt.Errorf("用户不存在") + } + if user.PartnerID == nil { + return nil, fmt.Errorf("请先完成伴侣绑定") + } + + // Determine whose whispers to fetch + var targetID string + if user.Role == model.RoleDad { + targetID = *user.PartnerID // Dad reads Mom's whispers + } else { + targetID = user.ID // Mom reads her own whispers + } + + whispers, err := s.whisperRepo.FindByAuthorID(targetID, 20) + if err != nil { + return nil, err + } + + items := make([]dto.WhisperItem, len(whispers)) + for i, w := range whispers { + items[i] = dto.WhisperItem{ + ID: w.ID, + Content: w.Content, + CreatedAt: w.CreatedAt, + } + } + return items, nil +} + +const whisperTipsPrompt = `你是「小石光」,一位温柔的家庭关系顾问。 +以下是一位妈妈最近写下的心语(心情、感受或愿望): + +%s + +请根据这些心语,给她的伴侣(爸爸)一些温暖、实用的提示和建议: +1. 先简要分析妈妈目前的情绪状态 +2. 给出 2-3 条具体的行动建议(比如可以做什么、说什么) +3. 语气温暖鼓励,像朋友之间的建议 + +用纯文本回复,不要使用 JSON 格式,1-3 段话即可。` + +// GetWhisperTips generates AI tips for Dad based on Mom's whispers. +func (s *WhisperService) GetWhisperTips(callerID string) (*dto.WhisperTips, error) { + user, err := s.userRepo.FindByID(callerID) + if err != nil { + return nil, fmt.Errorf("用户不存在") + } + if user.Role != model.RoleDad { + return nil, fmt.Errorf("只有爸爸角色可以获取提示") + } + if user.PartnerID == nil { + return nil, fmt.Errorf("请先完成伴侣绑定") + } + + whispers, err := s.whisperRepo.FindByAuthorID(*user.PartnerID, 10) + if err != nil { + return nil, err + } + + items := make([]dto.WhisperItem, len(whispers)) + for i, w := range whispers { + items[i] = dto.WhisperItem{ + ID: w.ID, + Content: w.Content, + CreatedAt: w.CreatedAt, + } + } + + if len(whispers) == 0 { + return &dto.WhisperTips{ + Tips: "她还没有写下心语,也许你可以主动关心一下她今天过得怎么样。", + Whispers: items, + }, nil + } + + // Build whisper context + var sb strings.Builder + for _, w := range whispers { + fmt.Fprintf(&sb, "- %s(%s)\n", w.Content, w.CreatedAt.Format("01-02 15:04")) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + messages := []openai.Message{ + {Role: "system", Content: fmt.Sprintf(whisperTipsPrompt, sb.String())}, + {Role: "user", Content: "请给出建议。"}, + } + + tips, err := s.aiClient.Chat(ctx, messages) + if err != nil { + log.Printf("[WhisperService] AI tips generation failed: %v", err) + return &dto.WhisperTips{ + Tips: "暂时无法生成建议,请稍后再试。", + Whispers: items, + }, nil + } + + return &dto.WhisperTips{ + Tips: strings.TrimSpace(tips), + Whispers: items, + }, nil +} diff --git a/backend/pkg/firecrawl/client.go b/backend/pkg/firecrawl/client.go new file mode 100644 index 00000000..defe97ae --- /dev/null +++ b/backend/pkg/firecrawl/client.go @@ -0,0 +1,79 @@ +package firecrawl + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" +) + +type Client struct { + apiKey string + http *http.Client +} + +func NewClient(apiKey string) *Client { + return &Client{ + apiKey: apiKey, + http: &http.Client{}, + } +} + +type SearchResult struct { + URL string `json:"url"` + Title string `json:"title"` + Description string `json:"description"` + Markdown string `json:"markdown"` +} + +type searchRequest struct { + Query string `json:"query"` + Limit int `json:"limit"` +} + +type searchResponse struct { + Success bool `json:"success"` + Data []SearchResult `json:"data"` +} + +func (c *Client) Search(ctx context.Context, query string, limit int) ([]SearchResult, error) { + if c.apiKey == "" { + return nil, nil + } + + body, err := json.Marshal(searchRequest{Query: query, Limit: limit}) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", "https://api.firecrawl.dev/v1/search", bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.apiKey) + + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("firecrawl search failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("firecrawl search failed: status %d, body: %s", resp.StatusCode, string(respBody)) + } + + var searchResp searchResponse + if err := json.Unmarshal(respBody, &searchResp); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return searchResp.Data, nil +} diff --git a/backend/pkg/openai/client.go b/backend/pkg/openai/client.go index 8dccdac1..da1da289 100644 --- a/backend/pkg/openai/client.go +++ b/backend/pkg/openai/client.go @@ -6,7 +6,9 @@ import ( "encoding/json" "fmt" "io" + "log" "net/http" + "time" ) type Client struct { @@ -57,7 +59,7 @@ func (c *Client) Chat(ctx context.Context, messages []Message) (string, error) { Model: c.model, Messages: messages, Temperature: 0.7, - MaxTokens: 1024, + MaxTokens: 4096, EnableThinking: false, } @@ -102,5 +104,217 @@ func (c *Client) Chat(ctx context.Context, messages []Message) (string, error) { return "", fmt.Errorf("openai returned no choices") } - return chatResp.Choices[0].Message.Content, nil + content := chatResp.Choices[0].Message.Content + log.Printf("[Chat] raw response (first 500 chars): %.500s", content) + return content, nil +} + +type imageRequest struct { + Model string `json:"model"` + Prompt string `json:"prompt"` + N int `json:"n,omitempty"` + Size string `json:"size,omitempty"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` + NumInferenceSteps int `json:"num_inference_steps,omitempty"` + GuidanceScale float64 `json:"guidance_scale"` +} + +type ImageResponse struct { + Data []struct { + URL string `json:"url,omitempty"` + B64JSON string `json:"b64_json,omitempty"` + } `json:"data"` + Error *struct { + Message string `json:"message"` + } `json:"error,omitempty"` +} + +func (c *Client) GenerateImage(ctx context.Context, model, prompt string) (*ImageResponse, error) { + reqBody := imageRequest{ + Model: model, + Prompt: prompt, + Width: 1024, + Height: 1024, + NumInferenceSteps: 9, + GuidanceScale: 0.0, + } + + body, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal image request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/images/generations", bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create image request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.apiKey) + req.Header.Set("X-ModelScope-Async-Mode", "true") + + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("image generation request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read image response: %w", err) + } + + log.Printf("[ImageGen] model=%s POST status=%d body_len=%d body_preview=%.500s", model, resp.StatusCode, len(respBody), string(respBody)) + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { + return nil, fmt.Errorf("image generation failed: status code: %d, message: %s", + resp.StatusCode, string(respBody)) + } + + // Try parsing as an async task response (ModelScope returns task_id for polling) + var taskResp taskStatusResponse + if err := json.Unmarshal(respBody, &taskResp); err == nil { + // If already SUCCEED with images, return directly + if imgResp := extractImagesFromTaskStatus(&taskResp); imgResp != nil { + return imgResp, nil + } + // Has task_id - poll with it (ModelScope uses task_id, NOT request_id) + if taskResp.TaskID != "" { + log.Printf("[ImageGen] async task, task_id=%s, status=%s, polling...", taskResp.TaskID, taskResp.TaskStatus) + return c.pollImageTask(ctx, taskResp.TaskID) + } + // Fallback: try request_id if no task_id + if taskResp.RequestID != "" { + log.Printf("[ImageGen] async task (fallback), request_id=%s, status=%s, polling...", taskResp.RequestID, taskResp.TaskStatus) + return c.pollImageTask(ctx, taskResp.RequestID) + } + } + + // Synchronous response (standard OpenAI format) + var imgResp ImageResponse + if err := json.Unmarshal(respBody, &imgResp); err != nil { + return nil, fmt.Errorf("failed to parse image response: %w", err) + } + + if imgResp.Error != nil { + return nil, fmt.Errorf("openai image error: %s", imgResp.Error.Message) + } + + if len(imgResp.Data) == 0 { + return nil, fmt.Errorf("image generation returned no results") + } + + return &imgResp, nil +} + +func extractImagesFromTaskStatus(status *taskStatusResponse) *ImageResponse { + if status.TaskStatus != "SUCCEED" && status.TaskStatus != "SUCCEEDED" { + return nil + } + + imgResp := &ImageResponse{} + + if len(status.OutputImages) > 0 { + imgResp.Data = make([]struct { + URL string `json:"url,omitempty"` + B64JSON string `json:"b64_json,omitempty"` + }, len(status.OutputImages)) + for i, u := range status.OutputImages { + imgResp.Data[i].URL = u + } + return imgResp + } + + if status.Output != nil && len(status.Output.Results) > 0 { + imgResp.Data = make([]struct { + URL string `json:"url,omitempty"` + B64JSON string `json:"b64_json,omitempty"` + }, len(status.Output.Results)) + for i, r := range status.Output.Results { + imgResp.Data[i].URL = r.URL + imgResp.Data[i].B64JSON = r.B64JSON + } + return imgResp + } + + return nil // Status is SUCCEED but no images found +} + +type taskStatusResponse struct { + RequestID string `json:"request_id"` + TaskID string `json:"task_id"` + TaskStatus string `json:"task_status"` + OutputImages []string `json:"output_images,omitempty"` + Output *struct { + TaskID string `json:"task_id"` + Results []struct { + URL string `json:"url,omitempty"` + B64JSON string `json:"b64_json,omitempty"` + } `json:"results"` + } `json:"output,omitempty"` + Error *struct { + Message string `json:"message"` + } `json:"error,omitempty"` + Message string `json:"message,omitempty"` +} + +func (c *Client) pollImageTask(ctx context.Context, taskID string) (*ImageResponse, error) { + pollURL := c.baseURL + "/tasks/" + taskID + + for i := 0; i < 40; i++ { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(10 * time.Second): + } + + req, err := http.NewRequestWithContext(ctx, "GET", pollURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create poll request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+c.apiKey) + req.Header.Set("X-ModelScope-Task-Type", "image_generation") + + resp, err := c.http.Do(req) + if err != nil { + log.Printf("[ImageGen] poll error: %v", err) + continue + } + + respBody, err := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if err != nil { + continue + } + + log.Printf("[ImageGen] poll #%d status=%d body_len=%d body_preview=%.500s", i+1, resp.StatusCode, len(respBody), string(respBody)) + + var status taskStatusResponse + if err := json.Unmarshal(respBody, &status); err != nil { + log.Printf("[ImageGen] poll parse error: %v", err) + continue + } + + if status.Error != nil { + return nil, fmt.Errorf("image task failed: %s", status.Error.Message) + } + + if imgResp := extractImagesFromTaskStatus(&status); imgResp != nil { + return imgResp, nil + } + + switch status.TaskStatus { + case "SUCCEED", "SUCCEEDED", "succeeded": + return nil, fmt.Errorf("image task succeeded but no results in response") + case "FAILED", "failed": + msg := status.Message + if msg == "" { + msg = "image generation task failed" + } + return nil, fmt.Errorf("%s", msg) + } + // PENDING, RUNNING - continue polling + } + + return nil, fmt.Errorf("image generation timed out after polling") } diff --git a/backend/uploads/avatars/0ea70a2d-f9ba-4bd9-8615-fbe6d5a8f421.jpg b/backend/uploads/avatars/0ea70a2d-f9ba-4bd9-8615-fbe6d5a8f421.jpg deleted file mode 100644 index 97475c83..00000000 --- a/backend/uploads/avatars/0ea70a2d-f9ba-4bd9-8615-fbe6d5a8f421.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:1f62ade99c320ef2cc6b03c1267b3e84e571c7d4b2e576accb438604913b8e5a -size 953068 diff --git a/backend/uploads/avatars/7489176a-d8b7-4cee-a3a3-76c4d2759292.png b/backend/uploads/avatars/7489176a-d8b7-4cee-a3a3-76c4d2759292.png deleted file mode 100644 index df568498..00000000 --- a/backend/uploads/avatars/7489176a-d8b7-4cee-a3a3-76c4d2759292.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:527e644d661085f0201e58e9b502dae8d9803281358da68d6950a43e89cc8e5f -size 414120 diff --git a/frontend.new/.omc/state/last-tool-error.json b/frontend.new/.omc/state/last-tool-error.json deleted file mode 100644 index 50c8bb76..00000000 --- a/frontend.new/.omc/state/last-tool-error.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "tool_name": "Bash", - "tool_input_preview": "{\"command\":\"cd /home/koishi/MomShell && git add frontend.new/src/components/scene/BeachScene.vue frontend.new/src/constants/layers.ts frontend.new/vite.config.ts && git rebase --continue\",\"description...", - "error": "Exit code 1\n[detached HEAD cc7ec54] refactor: restructure beach scene into Vue 3 + Vite + TypeScript\n 5 files changed, 2174 insertions(+), 1221 deletions(-)\n rename frontend.new/assets/{render-sand.js => render-sand.ts} (87%)\nRebasing (16/58)\rAuto-merging frontend.new/src/components/scene/BeachScene.vue\nCONFLICT (content): Merge conflict in frontend.new/src/components/scene/BeachScene.vue\nAuto-merging frontend.new/src/components/scene/SpritesLayer.vue\nCONFLICT (add/add): Merge conflict in fronte...", - "timestamp": "2026-03-07T06:54:53.196Z", - "retry_count": 1 -} \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 05e93e56..b72df70d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,11 +1,11 @@ { - "name": "beach-scene", + "name": "momshell-frontend", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "beach-scene", + "name": "momshell-frontend", "version": "1.0.0", "dependencies": { "axios": "^1.13.6", @@ -1204,6 +1204,7 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -1606,6 +1607,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2111,7 +2113,8 @@ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1581282.tgz", "integrity": "sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==", "dev": true, - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/dunder-proto": { "version": "1.0.1", @@ -2314,6 +2317,7 @@ "integrity": "sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -3920,6 +3924,7 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -3960,6 +3965,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4022,6 +4028,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -4103,6 +4110,7 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.29.tgz", "integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==", "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.29", "@vue/compiler-sfc": "3.5.29", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 0d0a36fd..b47ed11f 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -3,30 +3,35 @@ - + + - - diff --git a/frontend/src/components/overlay/TaskPanel.vue b/frontend/src/components/overlay/TaskPanel.vue new file mode 100644 index 00000000..95efe153 --- /dev/null +++ b/frontend/src/components/overlay/TaskPanel.vue @@ -0,0 +1,530 @@ + + + + + diff --git a/frontend/src/components/overlay/WhisperPanel.vue b/frontend/src/components/overlay/WhisperPanel.vue new file mode 100644 index 00000000..345284e8 --- /dev/null +++ b/frontend/src/components/overlay/WhisperPanel.vue @@ -0,0 +1,332 @@ +