Skip to content
Merged

Dev #130

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
2b6d115
chore: remove frontend.new
koishi510 Mar 7, 2026
7f77ad5
feat: add community AI replies and web search via Firecrawl
koishi510 Mar 7, 2026
22f2b20
feat: auto AI reply on all new posts and comment replies, with source…
koishi510 Mar 7, 2026
536d860
fix: renumber cited sources sequentially in AI replies
koishi510 Mar 7, 2026
9d46e22
fix: only cite sources for factual claims, not emotional support
koishi510 Mar 7, 2026
a90423a
chore: update avatars
koishi510 Mar 7, 2026
09e8b55
feat: add edit/delete UI for questions, answers, and comments
koishi510 Mar 7, 2026
9eadf2f
feat: use dedicated AI avatar and simplify mention pattern
koishi510 Mar 7, 2026
58c1df3
fix: cascade delete answers/comments and update sprites
koishi510 Mar 7, 2026
f297f39
feat(frontend): reorganize assets and add music volume control
koishi510 Mar 7, 2026
a1af6fb
docs(changelog): update unreleased frontend changes
koishi510 Mar 7, 2026
84ba317
chore: clean overlay profile duplication
koishi510 Mar 7, 2026
a01a0ba
fix: anchor crab speech bubble to sprite
koishi510 Mar 7, 2026
b9a300c
feat: add photo gallery with AI image generation
4rthurCai Mar 8, 2026
c062204
fix: strip JSON artifacts from memoir text output
4rthurCai Mar 8, 2026
0efd853
fix: check is_admin flag instead of role in admin login
4rthurCai Mar 8, 2026
b6c97e6
feat: add photo gallery frontend with AI photo in memoir
4rthurCai Mar 8, 2026
0d85996
chore: update profile panel, user API, and dev setup scripts
4rthurCai Mar 8, 2026
020de2f
feat: trigger AI reply on answers mentioning @小石光
koishi510 Mar 8, 2026
ba28e2d
feat: add whisper (conque) and task (star) features
koishi510 Mar 8, 2026
2742ba8
chore: remove unused asyncTaskResponse type
koishi510 Mar 8, 2026
dcdc1ad
feat: improve partner task UI and add reject/polling
4rthurCai Mar 9, 2026
6eb1ed7
chore: remove accidentally tracked avatar uploads
4rthurCai Mar 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
66 changes: 62 additions & 4 deletions backend/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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()
Expand All @@ -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,
Expand All @@ -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()
Expand All @@ -99,6 +126,7 @@ func main() {
authHandler, questionHandler, answerHandler,
commentHandler, interactionHandler, tagHandler,
chatHandler, echoHandler, userHandler, adminHandler,
photoHandler, whisperHandler, taskHandler,
)

// Start server
Expand Down Expand Up @@ -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
}
2 changes: 1 addition & 1 deletion backend/internal/admin/admin.html
Original file line number Diff line number Diff line change
Expand Up @@ -475,7 +475,7 @@ <h3 class="text-lg font-bold text-gray-800 mb-2">确认删除</h3>
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('需要管理员权限');
}

Expand Down
6 changes: 6 additions & 0 deletions backend/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ type Config struct {
OpenAIAPIKey string
OpenAIBaseURL string
OpenAIModel string
ImageModel string

// Firecrawl (web search)
FirecrawlAPIKey string

// Server
Port string
Expand All @@ -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", ""),
Expand Down
46 changes: 46 additions & 0 deletions backend/internal/database/migrate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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])
}
}
46 changes: 46 additions & 0 deletions backend/internal/dto/photo.go
Original file line number Diff line number Diff line change
@@ -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"`
}
27 changes: 27 additions & 0 deletions backend/internal/dto/task.go
Original file line number Diff line number Diff line change
@@ -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"`
}
18 changes: 18 additions & 0 deletions backend/internal/dto/whisper.go
Original file line number Diff line number Diff line change
@@ -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"`
}
10 changes: 8 additions & 2 deletions backend/internal/handler/answer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)})
}

Expand Down
10 changes: 8 additions & 2 deletions backend/internal/handler/comment.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}

Expand Down
Loading