diff --git a/.gitignore b/.gitignore index cef5d8dc..5121ffc2 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,7 @@ archive-legacy-*.tar.gz CLAUDE.md PROGRESS.md SKILL.md +PLAN.md # ============================================================================= # Misc diff --git a/backend/internal/database/migrate.go b/backend/internal/database/migrate.go index 3716446e..8293765c 100644 --- a/backend/internal/database/migrate.go +++ b/backend/internal/database/migrate.go @@ -38,6 +38,9 @@ func Migrate(db *gorm.DB) error { "role": "mom", }) + // Backfill OwnerUserID for existing ChatMemoryFacts + db.Exec("UPDATE chat_memory_facts SET owner_user_id = user_id WHERE owner_user_id IS NULL OR owner_user_id = ''") + return nil } diff --git a/backend/internal/dto/chat.go b/backend/internal/dto/chat.go index 5f01b7c0..fc6fc15f 100644 --- a/backend/internal/dto/chat.go +++ b/backend/internal/dto/chat.go @@ -38,6 +38,8 @@ type ChatMemoryFactDTO struct { ID string `json:"id"` Content string `json:"content"` Category string `json:"category"` + OwnerUserID string `json:"owner_user_id"` + OwnerNickname string `json:"owner_nickname"` CreatedAt string `json:"created_at"` LastReferencedAt *string `json:"last_referenced_at"` } diff --git a/backend/internal/dto/photo.go b/backend/internal/dto/photo.go index 462f6733..3ea8b53c 100644 --- a/backend/internal/dto/photo.go +++ b/backend/internal/dto/photo.go @@ -1,16 +1,18 @@ 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"` + 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"` + OwnerID string `json:"owner_id"` + OwnerNickname string `json:"owner_nickname"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } type PhotoListResponse struct { diff --git a/backend/internal/model/chat.go b/backend/internal/model/chat.go index 46a80a32..236cce3a 100644 --- a/backend/internal/model/chat.go +++ b/backend/internal/model/chat.go @@ -35,6 +35,7 @@ const ( type ChatMemoryFact struct { ID string `gorm:"type:varchar(36);primaryKey" json:"id"` UserID string `gorm:"type:varchar(36);index;not null" json:"user_id"` + OwnerUserID string `gorm:"type:varchar(36);index" json:"owner_user_id"` Content string `gorm:"type:text;not null" json:"content"` Category FactCategory `gorm:"type:varchar(30);default:'other'" json:"category"` CreatedAt time.Time `json:"created_at"` diff --git a/backend/internal/repository/chat.go b/backend/internal/repository/chat.go index 621eeb2a..8a8c6b53 100644 --- a/backend/internal/repository/chat.go +++ b/backend/internal/repository/chat.go @@ -1,6 +1,9 @@ package repository import ( + "strings" + "time" + "github.com/momshell/backend/internal/model" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -85,3 +88,67 @@ func (r *ChatRepo) TouchFactReferencedAt(ids []string) error { Where("id IN ?", ids). Update("last_referenced_at", gorm.Expr("NOW()")).Error } + +// FindDeletedFactsByUserID returns soft-deleted facts from the last 90 days. +func (r *ChatRepo) FindDeletedFactsByUserID(userID string) ([]model.ChatMemoryFact, error) { + var facts []model.ChatMemoryFact + cutoff := time.Now().AddDate(0, 0, -90) + err := r.db.Unscoped(). + Where("user_id = ? AND deleted_at IS NOT NULL AND deleted_at > ?", userID, cutoff). + Find(&facts).Error + return facts, err +} + +// DeleteFactsByContentLike soft-deletes facts whose content matches any of the given phrases. +func (r *ChatRepo) DeleteFactsByContentLike(userID string, phrases []string) error { + for _, phrase := range phrases { + phrase = strings.TrimSpace(phrase) + if phrase == "" { + continue + } + if err := r.db.Where("user_id = ? AND content LIKE ?", userID, "%"+phrase+"%"). + Delete(&model.ChatMemoryFact{}).Error; err != nil { + return err + } + } + return nil +} + +// --- Family (partner-shared) query methods --- + +func (r *ChatRepo) FindFactsByFamilyIDs(familyIDs []string) ([]model.ChatMemoryFact, error) { + var facts []model.ChatMemoryFact + err := r.db.Where("user_id IN ?", familyIDs).Order("created_at desc").Find(&facts).Error + return facts, err +} + +func (r *ChatRepo) FindDeletedFactsByFamilyIDs(familyIDs []string) ([]model.ChatMemoryFact, error) { + var facts []model.ChatMemoryFact + cutoff := time.Now().AddDate(0, 0, -90) + err := r.db.Unscoped(). + Where("user_id IN ? AND deleted_at IS NOT NULL AND deleted_at > ?", familyIDs, cutoff). + Find(&facts).Error + return facts, err +} + +func (r *ChatRepo) FactExistsByContentFamily(familyIDs []string, content string) (bool, error) { + var count int64 + err := r.db.Unscoped().Model(&model.ChatMemoryFact{}). + Where("user_id IN ? AND content = ?", familyIDs, content). + Count(&count).Error + return count > 0, err +} + +func (r *ChatRepo) DeleteFactsByContentLikeFamily(familyIDs []string, phrases []string) error { + for _, phrase := range phrases { + phrase = strings.TrimSpace(phrase) + if phrase == "" { + continue + } + if err := r.db.Where("user_id IN ? AND content LIKE ?", familyIDs, "%"+phrase+"%"). + Delete(&model.ChatMemoryFact{}).Error; err != nil { + return err + } + } + return nil +} diff --git a/backend/internal/repository/photo.go b/backend/internal/repository/photo.go index d4bfd213..30546d5b 100644 --- a/backend/internal/repository/photo.go +++ b/backend/internal/repository/photo.go @@ -147,3 +147,69 @@ func (r *PhotoRepo) FindAllPaginated(search, userID, source, onWall string, limi err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&photos).Error return photos, total, err } + +// --- Family (partner-shared) query methods --- + +func (r *PhotoRepo) FindByFamilyIDs(familyIDs []string, limit, offset int) ([]model.Photo, int64, error) { + query := r.db.Model(&model.Photo{}).Where("user_id IN ?", familyIDs) + + 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) FindWallPhotosByFamily(familyIDs []string) ([]model.Photo, error) { + var photos []model.Photo + err := r.db.Where("user_id IN ? AND is_on_wall = ?", familyIDs, true). + Order("wall_position asc").Find(&photos).Error + return photos, err +} + +func (r *PhotoRepo) CountByFamilyIDs(familyIDs []string) (int64, error) { + var count int64 + err := r.db.Model(&model.Photo{}).Where("user_id IN ?", familyIDs).Count(&count).Error + return count, err +} + +func (r *PhotoRepo) CountWallPhotosByFamily(familyIDs []string) (int64, error) { + var count int64 + err := r.db.Model(&model.Photo{}).Where("user_id IN ? AND is_on_wall = ?", familyIDs, true).Count(&count).Error + return count, err +} + +func (r *PhotoRepo) FindByIDAndFamilyIDs(id string, familyIDs []string) (*model.Photo, error) { + var photo model.Photo + err := r.db.Where("id = ? AND user_id IN ?", id, familyIDs).First(&photo).Error + if err != nil { + return nil, err + } + return &photo, nil +} + +func (r *PhotoRepo) BatchUpdateWallFamily(familyIDs []string, updates []WallUpdate) error { + return r.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&model.Photo{}). + Where("user_id IN ? AND is_on_wall = ?", familyIDs, true). + Updates(map[string]any{"is_on_wall": false, "wall_position": nil}).Error; err != nil { + return err + } + for _, u := range updates { + pos := u.Position + if err := tx.Model(&model.Photo{}). + Where("id = ? AND user_id IN ?", u.PhotoID, familyIDs). + Updates(map[string]any{"is_on_wall": true, "wall_position": pos}).Error; err != nil { + return err + } + } + return nil + }) +} + +func (r *PhotoRepo) DeleteByFamily(id string, familyIDs []string) error { + return r.db.Where("id = ? AND user_id IN ?", id, familyIDs).Delete(&model.Photo{}).Error +} diff --git a/backend/internal/service/chat.go b/backend/internal/service/chat.go index 4aa4bc44..eba3fccc 100644 --- a/backend/internal/service/chat.go +++ b/backend/internal/service/chat.go @@ -43,6 +43,7 @@ const companionSystemPromptMom = `你是「小石光」,一位真诚的知心 %s 在回应时,自然地融入这些记忆。 +重要:上方「重要信息」列表是最准确的记忆来源。如果对话片段中出现了不在此列表中的信息,说明用户已删除或更正,请勿重新记录。 ## 响应格式 @@ -52,7 +53,10 @@ const companionSystemPromptMom = `你是「小石光」,一位真诚的知心 - effect_type: "ripple" | "sunlight" | "calm" | "warm_glow" | "gentle_wave" - intensity: 0.0 ~ 1.0 - color_tone: "soft_pink" | "warm_gold" | "gentle_blue" | "lavender" | "neutral_white" | "coral" | "sage" -3. **memory_extract**: 如果用户分享了值得记住的信息,提取为 JSON 对象(包含 facts 字符串数组,每条用一句话概括一个重要信息,如"宝宝叫小米"、"最近在学烘焙"、"老公经常出差");否则为 null +3. **memory_extract**: 如果用户分享了值得记住的信息或更正了之前的说法,提取为 JSON 对象: + - **facts**: 数组,每条包含 content(一句话概括)和 category(分类:personal_info/family/interest/concern/preference/other),如 [{"content": "爱吃苹果", "category": "preference"}, {"content": "宝宝叫小米", "category": "family"}] + - **corrections**: 如果用户更正了之前的说法(如"我之前说错了"、"其实不是…"、"不对,应该是…"),列出需要删除的旧信息关键词,如 ["爱吃苹果"];没有更正则省略此字段 + 没有需要记录或更正的信息时为 null 记住:你的存在不是为了「解决她的问题」,而是让她感到——在这一刻,有人真正看见了她。` @@ -80,6 +84,7 @@ const companionSystemPromptDad = `你是「小石光」,一位耐心的同行 %s 在回应时,自然地融入这些记忆。 +重要:上方「重要信息」列表是最准确的记忆来源。如果对话片段中出现了不在此列表中的信息,说明用户已删除或更正,请勿重新记录。 ## 响应格式 @@ -89,7 +94,10 @@ const companionSystemPromptDad = `你是「小石光」,一位耐心的同行 - effect_type: "ripple" | "sunlight" | "calm" | "warm_glow" | "gentle_wave" - intensity: 0.0 ~ 1.0 - color_tone: "soft_pink" | "warm_gold" | "gentle_blue" | "lavender" | "neutral_white" | "coral" | "sage" -3. **memory_extract**: 如果用户分享了值得记住的信息,提取为 JSON 对象(包含 facts 字符串数组,每条用一句话概括一个重要信息,如"宝宝叫小米"、"最近在学烘焙"、"老公经常出差");否则为 null +3. **memory_extract**: 如果用户分享了值得记住的信息或更正了之前的说法,提取为 JSON 对象: + - **facts**: 数组,每条包含 content(一句话概括)和 category(分类:personal_info/family/interest/concern/preference/other),如 [{"content": "爱吃苹果", "category": "preference"}, {"content": "宝宝叫小米", "category": "family"}] + - **corrections**: 如果用户更正了之前的说法(如"我之前说错了"、"其实不是…"、"不对,应该是…"),列出需要删除的旧信息关键词,如 ["爱吃苹果"];没有更正则省略此字段 + 没有需要记录或更正的信息时为 null 记住:你的存在是帮他成为更好的自己——看清方向,迈出下一步。` @@ -117,6 +125,7 @@ const companionSystemPromptProfessional = `你是「小石光」,一位尊重 %s 在回应时,自然地融入这些记忆。 +重要:上方「重要信息」列表是最准确的记忆来源。如果对话片段中出现了不在此列表中的信息,说明用户已删除或更正,请勿重新记录。 ## 响应格式 @@ -126,7 +135,10 @@ const companionSystemPromptProfessional = `你是「小石光」,一位尊重 - effect_type: "ripple" | "sunlight" | "calm" | "warm_glow" | "gentle_wave" - intensity: 0.0 ~ 1.0 - color_tone: "soft_pink" | "warm_gold" | "gentle_blue" | "lavender" | "neutral_white" | "coral" | "sage" -3. **memory_extract**: 如果用户分享了值得记住的信息,提取为 JSON 对象(包含 facts 字符串数组,每条用一句话概括一个重要信息,如"宝宝叫小米"、"最近在学烘焙"、"老公经常出差");否则为 null +3. **memory_extract**: 如果用户分享了值得记住的信息或更正了之前的说法,提取为 JSON 对象: + - **facts**: 数组,每条包含 content(一句话概括)和 category(分类:personal_info/family/interest/concern/preference/other),如 [{"content": "爱吃苹果", "category": "preference"}, {"content": "宝宝叫小米", "category": "family"}] + - **corrections**: 如果用户更正了之前的说法(如"我之前说错了"、"其实不是…"、"不对,应该是…"),列出需要删除的旧信息关键词,如 ["爱吃苹果"];没有更正则省略此字段 + 没有需要记录或更正的信息时为 null 记住:你的存在是提供一个对等的、可以卸下专业面具的空间。` @@ -200,6 +212,27 @@ func NewChatService(client *openai.Client, chatRepo *repository.ChatRepo, userRe } } +func (s *ChatService) getFamilyIDs(userID string) []string { + user, err := s.userRepo.FindByID(userID) + if err != nil { + return []string{userID} + } + if user.PartnerID != nil && *user.PartnerID != "" { + return []string{userID, *user.PartnerID} + } + return []string{userID} +} + +func (s *ChatService) buildNicknameMap(familyIDs []string) map[string]string { + m := make(map[string]string, len(familyIDs)) + for _, id := range familyIDs { + if u, err := s.userRepo.FindByID(id); err == nil { + m[id] = u.Nickname + } + } + return m +} + func (s *ChatService) Chat(ctx context.Context, msg dto.UserMessage, userID string) (*dto.VisualResponse, error) { if userID != "" { return s.chatAuthenticated(ctx, msg, userID) @@ -208,29 +241,53 @@ func (s *ChatService) Chat(ctx context.Context, msg dto.UserMessage, userID stri } func (s *ChatService) chatAuthenticated(ctx context.Context, msg dto.UserMessage, userID string) (*dto.VisualResponse, error) { - // Look up user role + // Look up user role and partner info role := model.RoleMom isAdmin := false + var partnerID string + var partnerRole model.UserRole if user, err := s.userRepo.FindByID(userID); err == nil { role = user.Role isAdmin = user.IsAdmin + if user.PartnerID != nil && *user.PartnerID != "" { + partnerID = *user.PartnerID + if partner, err := s.userRepo.FindByID(partnerID); err == nil { + partnerRole = partner.Role + } + } } pronoun := pronounFor(role) - // Load memory from DB + familyIDs := []string{userID} + if partnerID != "" { + familyIDs = append(familyIDs, partnerID) + } + + // Load memory from DB (per-user, not shared) profile, turns, summary := s.loadUserMemory(userID) - // Load structured facts for prompt - factsText := s.loadFactsForPrompt(userID) + // Load structured facts for prompt (family-scoped) + factsText, deletedFactsText := s.loadFactsForPrompt(userID, familyIDs, role, partnerRole) systemPrompt := fmt.Sprintf(getCompanionPrompt(role, isAdmin), formatProfile(profile, pronoun, factsText), formatTurns(turns, summary, pronoun), ) + // Update memory section header for family mode + if partnerID != "" { + for _, old := range []string{"你记得关于她的重要信息", "你记得关于他的重要信息", "你记得关于对方的重要信息"} { + systemPrompt = strings.Replace(systemPrompt, old, "你记得关于这个家庭的重要信息", 1) + } + } + + if deletedFactsText != "" { + systemPrompt += "\n\n### 已删除的记忆(用户已删除或更正,请勿重新记录)\n" + deletedFactsText + } + webResults := s.searchWebForChat(ctx, msg.Content) if webResults != "" { - systemPrompt += "\n\n## 联网搜索参考\n" + webResults + "\n如有引用搜索内容,请自然融入回答,标注来源。不确定的信息请标明。" + systemPrompt += "\n\n## 联网搜索参考\n" + webResults + "\n日常聊天不需要引用来源。仅在提供专业性建议时才引用,引用时直接写出具体来源名称(如「根据XX的一篇文章...」),不要使用[来源1]这样的标注。不确定的信息请标明。" } messages := []openai.Message{ @@ -248,8 +305,15 @@ func (s *ChatService) chatAuthenticated(ctx context.Context, msg dto.UserMessage // Update profile from extract memoryUpdated := updateProfileFromExtract(profile, parsed["memory_extract"]) - // Save structured facts (Phase 3) - s.saveFactsFromExtract(userID, parsed["memory_extract"]) + // Save structured facts (Phase 3) - with OwnerUserID + if s.saveFactsFromExtract(userID, parsed["memory_extract"]) { + memoryUpdated = true + } + + // Process corrections (delete outdated facts) in family scope + if s.processMemoryCorrections(familyIDs, parsed["memory_extract"], profile) { + memoryUpdated = true + } // Append new turn turns = append(turns, map[string]interface{}{ @@ -319,7 +383,7 @@ func (s *ChatService) chatGuest(ctx context.Context, msg dto.UserMessage) (*dto. webResults := s.searchWebForChat(ctx, msg.Content) if webResults != "" { - systemPrompt += "\n\n## 联网搜索参考\n" + webResults + "\n如有引用搜索内容,请自然融入回答,标注来源。不确定的信息请标明。" + systemPrompt += "\n\n## 联网搜索参考\n" + webResults + "\n日常聊天不需要引用来源。仅在提供专业性建议时才引用,引用时直接写出具体来源名称(如「根据XX的一篇文章...」),不要使用[来源1]这样的标注。不确定的信息请标明。" } messages := []openai.Message{ @@ -401,27 +465,43 @@ func (s *ChatService) generateAndSaveSummary(userID string, existingSummary stri // --- Phase 3: Structured Memory Facts --- -func (s *ChatService) saveFactsFromExtract(userID string, extract interface{}) { +func (s *ChatService) saveFactsFromExtract(userID string, extract interface{}) bool { if extract == nil { - return + return false } extractMap, ok := extract.(map[string]interface{}) if !ok { - return + return false } facts, ok := extractMap["facts"].([]interface{}) if !ok || len(facts) == 0 { - return + return false } + saved := false for _, v := range facts { - content, ok := v.(string) - if !ok || strings.TrimSpace(content) == "" { + var content string + var aiCategory string + + switch item := v.(type) { + case map[string]interface{}: + // New structured format: {"content": "...", "category": "..."} + c, _ := item["content"].(string) + content = strings.TrimSpace(c) + cat, _ := item["category"].(string) + aiCategory = strings.TrimSpace(cat) + case string: + // Legacy string format + content = strings.TrimSpace(item) + default: continue } - content = strings.TrimSpace(content) - // Skip if identical fact already exists + if content == "" { + continue + } + + // Skip if identical fact already exists for this user (including soft-deleted) exists, err := s.chatRepo.FactExistsByContent(userID, content) if err != nil { log.Printf("[ChatService] failed to check fact existence: %v", err) @@ -431,16 +511,83 @@ func (s *ChatService) saveFactsFromExtract(userID string, extract interface{}) { continue } - category := categorizeFactContent(content) + category := resolveFactCategory(aiCategory, content) fact := &model.ChatMemoryFact{ - UserID: userID, - Content: content, - Category: category, + UserID: userID, + OwnerUserID: userID, + Content: content, + Category: category, } if err := s.chatRepo.CreateFact(fact); err != nil { log.Printf("[ChatService] failed to save fact for user %s: %v", userID, err) + } else { + saved = true + } + } + return saved +} + +// processMemoryCorrections handles user corrections by fuzzy-deleting matching facts in family scope. +func (s *ChatService) processMemoryCorrections(familyIDs []string, extract interface{}, profile map[string]interface{}) bool { + if extract == nil { + return false + } + extractMap, ok := extract.(map[string]interface{}) + if !ok { + return false + } + corrections, ok := extractMap["corrections"].([]interface{}) + if !ok || len(corrections) == 0 { + return false + } + + phrases := make([]string, 0, len(corrections)) + for _, v := range corrections { + if str, ok := v.(string); ok && strings.TrimSpace(str) != "" { + phrases = append(phrases, strings.TrimSpace(str)) + } + } + if len(phrases) == 0 { + return false + } + + corrected := false + if err := s.chatRepo.DeleteFactsByContentLikeFamily(familyIDs, phrases); err != nil { + log.Printf("[ChatService] failed to process memory corrections: %v", err) + } else { + corrected = true + } + + // Also clean legacy profile facts + if legacyFacts, ok := profile["facts"].([]interface{}); ok && len(legacyFacts) > 0 { + var remaining []interface{} + for _, fact := range legacyFacts { + factStr := fmt.Sprintf("%v", fact) + matched := false + for _, phrase := range phrases { + if strings.Contains(factStr, phrase) { + matched = true + break + } + } + if !matched { + remaining = append(remaining, fact) + } } + profile["facts"] = remaining } + return corrected +} + +// resolveFactCategory uses the AI-provided category if valid, otherwise falls back to keyword detection. +func resolveFactCategory(aiCategory string, content string) model.FactCategory { + switch model.FactCategory(aiCategory) { + case model.FactCategoryPersonalInfo, model.FactCategoryFamily, + model.FactCategoryInterest, model.FactCategoryConcern, + model.FactCategoryPreference, model.FactCategoryOther: + return model.FactCategory(aiCategory) + } + return categorizeFactContent(content) } func categorizeFactContent(content string) model.FactCategory { @@ -451,63 +598,114 @@ func categorizeFactContent(content string) model.FactCategory { strings.Contains(lower, "伴侣") || strings.Contains(lower, "家人") || strings.Contains(lower, "父母") || strings.Contains(lower, "婆婆"): return model.FactCategoryFamily - case strings.Contains(lower, "喜欢") || strings.Contains(lower, "爱好") || - strings.Contains(lower, "兴趣") || strings.Contains(lower, "在学"): - return model.FactCategoryInterest - case strings.Contains(lower, "担心") || strings.Contains(lower, "焦虑") || - strings.Contains(lower, "害怕") || strings.Contains(lower, "困扰"): - return model.FactCategoryConcern + case strings.Contains(lower, "爱吃") || strings.Contains(lower, "不爱吃") || + strings.Contains(lower, "最爱") || strings.Contains(lower, "喜爱") || + strings.Contains(lower, "讨厌") || strings.Contains(lower, "想吃") || + strings.Contains(lower, "常吃") || strings.Contains(lower, "爱喝") || + strings.Contains(lower, "爱看") || strings.Contains(lower, "爱听") || + strings.Contains(lower, "最喜欢") || strings.Contains(lower, "不想") || + strings.Contains(lower, "受不了") || + strings.Contains(lower, "喜欢") || strings.Contains(lower, "偏好") || + strings.Contains(lower, "习惯") || strings.Contains(lower, "不喜欢"): + return model.FactCategoryPreference case strings.Contains(lower, "叫") || strings.Contains(lower, "名字") || strings.Contains(lower, "岁") || strings.Contains(lower, "职业") || - strings.Contains(lower, "住在"): + strings.Contains(lower, "住在") || + strings.Contains(lower, "工作") || strings.Contains(lower, "公司") || + strings.Contains(lower, "城市") || strings.Contains(lower, "来自") || + strings.Contains(lower, "毕业") || strings.Contains(lower, "专业"): return model.FactCategoryPersonalInfo - case strings.Contains(lower, "偏好") || strings.Contains(lower, "习惯") || - strings.Contains(lower, "不喜欢"): - return model.FactCategoryPreference + case strings.Contains(lower, "担心") || strings.Contains(lower, "焦虑") || + strings.Contains(lower, "害怕") || strings.Contains(lower, "困扰") || + strings.Contains(lower, "希望") || strings.Contains(lower, "愿望") || + strings.Contains(lower, "烦") || strings.Contains(lower, "压力") || + strings.Contains(lower, "累") || strings.Contains(lower, "纠结") || + strings.Contains(lower, "迷茫"): + return model.FactCategoryConcern + case strings.Contains(lower, "爱好") || strings.Contains(lower, "兴趣") || + strings.Contains(lower, "在学") || + strings.Contains(lower, "爱") || strings.Contains(lower, "在玩") || + strings.Contains(lower, "在看") || strings.Contains(lower, "在读") || + strings.Contains(lower, "在听") || strings.Contains(lower, "开始学") || + strings.Contains(lower, "报了"): + return model.FactCategoryInterest default: return model.FactCategoryOther } } -func (s *ChatService) loadFactsForPrompt(userID string) string { - facts, err := s.chatRepo.FindFactsByUserID(userID) - if err != nil || len(facts) == 0 { - return "" +func (s *ChatService) loadFactsForPrompt(userID string, familyIDs []string, userRole model.UserRole, partnerRole model.UserRole) (string, string) { + hasPartner := len(familyIDs) > 1 + + facts, err := s.chatRepo.FindFactsByFamilyIDs(familyIDs) + if err != nil { + facts = nil } - // Track referenced fact IDs to update last_referenced_at + // Active facts ids := make([]string, 0, len(facts)) - var sb strings.Builder for _, f := range facts { - fmt.Fprintf(&sb, " · %s\n", f.Content) + if hasPartner { + var label string + if f.Category == model.FactCategoryFamily { + label = "家庭" + } else if f.OwnerUserID == userID { + label = pronounFor(userRole) + } else { + label = pronounFor(partnerRole) + } + fmt.Fprintf(&sb, " · [%s] %s\n", label, f.Content) + } else { + fmt.Fprintf(&sb, " · %s\n", f.Content) + } ids = append(ids, f.ID) } // Update last_referenced_at in background - go func() { - if err := s.chatRepo.TouchFactReferencedAt(ids); err != nil { - log.Printf("[ChatService] failed to touch fact referenced_at: %v", err) + if len(ids) > 0 { + go func() { + if err := s.chatRepo.TouchFactReferencedAt(ids); err != nil { + log.Printf("[ChatService] failed to touch fact referenced_at: %v", err) + } + }() + } + + // Deleted facts in family scope (prevent re-learning) + var deletedSB strings.Builder + deletedFacts, err := s.chatRepo.FindDeletedFactsByFamilyIDs(familyIDs) + if err == nil { + for _, f := range deletedFacts { + fmt.Fprintf(&deletedSB, "- %s\n", f.Content) } - }() + } - return sb.String() + return sb.String(), deletedSB.String() } -// GetMemories returns all structured memory facts for a user (Phase 3 API). +// GetMemories returns all structured memory facts for the family (Phase 3 API). func (s *ChatService) GetMemories(userID string) (*dto.ChatMemoryFactsResponse, error) { - facts, err := s.chatRepo.FindFactsByUserID(userID) + familyIDs := s.getFamilyIDs(userID) + nicknameMap := s.buildNicknameMap(familyIDs) + + facts, err := s.chatRepo.FindFactsByFamilyIDs(familyIDs) if err != nil { return &dto.ChatMemoryFactsResponse{Facts: []dto.ChatMemoryFactDTO{}, Total: 0}, nil } items := make([]dto.ChatMemoryFactDTO, 0, len(facts)) for _, f := range facts { + ownerID := f.OwnerUserID + if ownerID == "" { + ownerID = f.UserID + } item := dto.ChatMemoryFactDTO{ - ID: f.ID, - Content: f.Content, - Category: string(f.Category), - CreatedAt: f.CreatedAt.Format(time.RFC3339), + ID: f.ID, + Content: f.Content, + Category: string(f.Category), + OwnerUserID: ownerID, + OwnerNickname: nicknameMap[ownerID], + CreatedAt: f.CreatedAt.Format(time.RFC3339), } if f.LastReferencedAt != nil { t := f.LastReferencedAt.Format(time.RFC3339) @@ -519,13 +717,22 @@ func (s *ChatService) GetMemories(userID string) (*dto.ChatMemoryFactsResponse, return &dto.ChatMemoryFactsResponse{Facts: items, Total: len(items)}, nil } -// DeleteMemory deletes a single memory fact, verifying ownership. +// DeleteMemory deletes a single memory fact, verifying family ownership. func (s *ChatService) DeleteMemory(userID, factID string) error { fact, err := s.chatRepo.FindFactByID(factID) if err != nil { return fmt.Errorf("记忆条目不存在") } - if fact.UserID != userID { + // Allow deletion if the fact belongs to any family member + familyIDs := s.getFamilyIDs(userID) + allowed := false + for _, id := range familyIDs { + if fact.UserID == id { + allowed = true + break + } + } + if !allowed { return fmt.Errorf("无权删除此记忆") } return s.chatRepo.DeleteFact(factID) @@ -571,7 +778,7 @@ func (s *ChatService) searchWebForChat(ctx context.Context, userMessage string) return "" } var sb strings.Builder - for i, r := range results { + for _, r := range results { content := r.Markdown if content == "" { content = r.Description @@ -579,7 +786,7 @@ func (s *ChatService) searchWebForChat(ctx context.Context, userMessage string) 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) + fmt.Fprintf(&sb, "来源「%s」(%s):%s\n", r.Title, r.URL, content) } return sb.String() } @@ -651,15 +858,9 @@ func formatProfile(profile map[string]interface{}, pronoun string, factsText str } } - // Structured facts from DB (Phase 3) take priority + // Structured facts from DB (Phase 3) if factsText != "" { parts += "- 重要信息:\n" + factsText - } else if facts, ok := profile["facts"].([]interface{}); ok && len(facts) > 0 { - // Fallback to legacy profile facts - parts += "- 重要信息:\n" - for _, v := range facts { - parts += fmt.Sprintf(" · %v\n", v) - } } if interests, ok := profile["interests"].([]interface{}); ok && len(interests) > 0 { @@ -781,12 +982,6 @@ func updateProfileFromExtract(profile map[string]interface{}, extract interface{ profile["concerns"] = deduplicateAndCap(existing, concerns, 20) updated = true } - if facts, ok := extractMap["facts"].([]interface{}); ok && len(facts) > 0 { - existing, _ := profile["facts"].([]interface{}) - profile["facts"] = deduplicateAndCap(existing, facts, 20) - updated = true - } - return updated } diff --git a/backend/internal/service/community_ai.go b/backend/internal/service/community_ai.go index 5a21a8d2..e76c8a52 100644 --- a/backend/internal/service/community_ai.go +++ b/backend/internal/service/community_ai.go @@ -260,7 +260,7 @@ func (s *CommunityAIService) searchWeb(ctx context.Context, query string) (strin 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) + fmt.Fprintf(&sb, "来源「%s」(%s):\n%s\n\n", r.Title, r.URL, content) sources = append(sources, sourceRef{index: i + 1, title: r.Title, url: r.URL}) } return sb.String(), sources @@ -296,12 +296,14 @@ const communityAISystemPromptMom = `你是「小石光」,一位真诚的知 ## 联网搜索结果 %s -## 防幻觉规则(严格遵守) +## 引用与防幻觉规则(严格遵守) 1. 只基于上述帖子上下文和搜索结果回答 -2. 仅在提供事实性信息时引用来源(如医学知识、研究数据),日常共情和鼓励不需要引用 -3. 如果不确定,明确说「关于这一点我不太确定,建议咨询专业人士」 -4. 绝不编造医疗数据、药物剂量、具体治疗方案 -5. 涉及医疗问题时,始终建议咨询专业医生 +2. 日常共情、鼓励、生活建议不需要添加引用来源 +3. 仅在提供专业性建议时(如医学知识、研究数据、权威指南)才引用来源 +4. 引用来源时,直接在回复中写出具体来源名称和链接(如「根据XX的一篇文章(链接)...」),不要使用[来源1]这样的标注方式 +5. 如果不确定,明确说「关于这一点我不太确定,建议咨询专业人士」 +6. 绝不编造医疗数据、药物剂量、具体治疗方案 +7. 涉及医疗问题时,始终建议咨询专业医生 ## 回复要求 - 用纯文本回复,不要使用 JSON 格式 @@ -329,12 +331,14 @@ const communityAISystemPromptDad = `你是「小石光」,一位耐心的同 ## 联网搜索结果 %s -## 防幻觉规则(严格遵守) +## 引用与防幻觉规则(严格遵守) 1. 只基于上述帖子上下文和搜索结果回答 -2. 仅在提供事实性信息时引用来源(如医学知识、研究数据),日常建议不需要引用 -3. 如果不确定,明确说「关于这一点我不太确定,建议咨询专业人士」 -4. 绝不编造医疗数据、药物剂量、具体治疗方案 -5. 涉及医疗问题时,始终建议咨询专业医生 +2. 日常共情、鼓励、生活建议不需要添加引用来源 +3. 仅在提供专业性建议时(如医学知识、研究数据、权威指南)才引用来源 +4. 引用来源时,直接在回复中写出具体来源名称和链接(如「根据XX的一篇文章(链接)...」),不要使用[来源1]这样的标注方式 +5. 如果不确定,明确说「关于这一点我不太确定,建议咨询专业人士」 +6. 绝不编造医疗数据、药物剂量、具体治疗方案 +7. 涉及医疗问题时,始终建议咨询专业医生 ## 回复要求 - 用纯文本回复,不要使用 JSON 格式 @@ -362,12 +366,14 @@ const communityAISystemPromptProfessional = `你是「小石光」,正在社 ## 联网搜索结果 %s -## 防幻觉规则(严格遵守) +## 引用与防幻觉规则(严格遵守) 1. 只基于上述帖子上下文和搜索结果回答 -2. 仅在提供事实性信息时引用来源 -3. 如果不确定,坦诚说明 -4. 绝不编造医疗数据、药物剂量、具体治疗方案 -5. 涉及具体诊疗方案时,建议结合临床实际判断 +2. 日常交流不需要添加引用来源 +3. 仅在提供专业性建议时(如引用研究数据、临床指南)才引用来源 +4. 引用来源时,直接在回复中写出具体来源名称和链接(如「根据XX的一篇文章(链接)...」),不要使用[来源1]这样的标注方式 +5. 如果不确定,坦诚说明 +6. 绝不编造医疗数据、药物剂量、具体治疗方案 +7. 涉及具体诊疗方案时,建议结合临床实际判断 ## 回复要求 - 用纯文本回复,不要使用 JSON 格式 @@ -415,65 +421,42 @@ func (s *CommunityAIService) generateReply(ctx context.Context, threadContext, s reply = "谢谢你的分享。如果需要更多帮助,随时可以找我聊聊。" } - // Append footnotes for any cited sources - reply = appendSourceFootnotes(reply, sources) + // Replace [来源N] markers with actual source names (fallback) + reply = replaceSourceReferences(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 { +// replaceSourceReferences replaces any [来源N] markers in the reply with +// the actual source title and URL inline, as a fallback in case the AI +// still uses the old numbered citation format. +func replaceSourceReferences(reply string, sources []sourceRef) string { + if 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 + matches := sourceRefPattern.FindAllStringSubmatch(reply, -1) + if len(matches) == 0 { + return reply } - // 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) + // Replace [来源N] with actual source name and URL inline + return sourceRefPattern.ReplaceAllStringFunc(reply, func(match string) string { + sub := sourceRefPattern.FindStringSubmatch(match) + var idx int + if _, err := fmt.Sscanf(sub[1], "%d", &idx); err == nil { + if src, ok := sourceMap[idx]; ok { + return fmt.Sprintf("(来源:%s %s)", src.title, src.url) + } } - } - - if footnotes.Len() > 0 { - replaced += "\n" + footnotes.String() - } - return replaced + return match + }) } diff --git a/backend/internal/service/photo.go b/backend/internal/service/photo.go index 2af6ff98..972963f0 100644 --- a/backend/internal/service/photo.go +++ b/backend/internal/service/photo.go @@ -24,8 +24,8 @@ import ( ) const ( - maxPhotosPerUser = 25 - maxWallPhotos = 10 + maxPhotosPerFamily = 50 + maxWallPhotos = 10 ) type PhotoService struct { @@ -44,6 +44,27 @@ func NewPhotoService(photoRepo *repository.PhotoRepo, userRepo *repository.UserR } } +func (s *PhotoService) getFamilyIDs(userID string) []string { + user, err := s.userRepo.FindByID(userID) + if err != nil { + return []string{userID} + } + if user.PartnerID != nil && *user.PartnerID != "" { + return []string{userID, *user.PartnerID} + } + return []string{userID} +} + +func (s *PhotoService) buildNicknameMap(familyIDs []string) map[string]string { + m := make(map[string]string, len(familyIDs)) + for _, id := range familyIDs { + if u, err := s.userRepo.FindByID(id); err == nil { + m[id] = u.Nickname + } + } + return m +} + func (s *PhotoService) ListPhotos(userID string, page, pageSize int) (*dto.PhotoListResponse, error) { if page < 1 { page = 1 @@ -53,16 +74,18 @@ func (s *PhotoService) ListPhotos(userID string, page, pageSize int) (*dto.Photo } offset := (page - 1) * pageSize - photos, total, err := s.photoRepo.FindByUserID(userID, pageSize, offset) + familyIDs := s.getFamilyIDs(userID) + photos, total, err := s.photoRepo.FindByFamilyIDs(familyIDs, pageSize, offset) if err != nil { return nil, fmt.Errorf("failed to list photos: %w", err) } totalPages := int(math.Ceil(float64(total) / float64(pageSize))) + nicknameMap := s.buildNicknameMap(familyIDs) items := make([]dto.PhotoResponse, 0, len(photos)) for _, p := range photos { - items = append(items, toPhotoResponse(p)) + items = append(items, toPhotoResponse(p, nicknameMap[p.UserID])) } return &dto.PhotoListResponse{ @@ -75,21 +98,24 @@ func (s *PhotoService) ListPhotos(userID string, page, pageSize int) (*dto.Photo } func (s *PhotoService) GetPhoto(id, userID string) (*dto.PhotoResponse, error) { - photo, err := s.photoRepo.FindByIDAndUserID(id, userID) + familyIDs := s.getFamilyIDs(userID) + photo, err := s.photoRepo.FindByIDAndFamilyIDs(id, familyIDs) if err != nil { return nil, fmt.Errorf("photo not found") } - resp := toPhotoResponse(*photo) + nicknameMap := s.buildNicknameMap(familyIDs) + resp := toPhotoResponse(*photo, nicknameMap[photo.UserID]) return &resp, nil } func (s *PhotoService) CreateFromUpload(userID, title, imageURL string) (*dto.PhotoResponse, error) { - count, err := s.photoRepo.CountByUserID(userID) + familyIDs := s.getFamilyIDs(userID) + count, err := s.photoRepo.CountByFamilyIDs(familyIDs) 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) + if count >= maxPhotosPerFamily { + return nil, fmt.Errorf("photo limit reached (max %d)", maxPhotosPerFamily) } photo := &model.Photo{ @@ -103,7 +129,8 @@ func (s *PhotoService) CreateFromUpload(userID, title, imageURL string) (*dto.Ph return nil, fmt.Errorf("failed to create photo: %w", err) } - resp := toPhotoResponse(*photo) + nicknameMap := s.buildNicknameMap(familyIDs) + resp := toPhotoResponse(*photo, nicknameMap[userID]) return &resp, nil } @@ -112,12 +139,13 @@ func (s *PhotoService) GeneratePhoto(ctx context.Context, userID string, req dto return nil, fmt.Errorf("image generation is not configured") } - count, err := s.photoRepo.CountByUserID(userID) + familyIDs := s.getFamilyIDs(userID) + count, err := s.photoRepo.CountByFamilyIDs(familyIDs) 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) + if count >= maxPhotosPerFamily { + return nil, fmt.Errorf("photo limit reached (max %d)", maxPhotosPerFamily) } var userRole string @@ -148,12 +176,14 @@ func (s *PhotoService) GeneratePhoto(ctx context.Context, userID string, req dto return nil, fmt.Errorf("failed to create photo: %w", err) } - resp := toPhotoResponse(*photo) + nicknameMap := s.buildNicknameMap(familyIDs) + resp := toPhotoResponse(*photo, nicknameMap[userID]) return &resp, nil } func (s *PhotoService) UpdatePhoto(id, userID string, req dto.UpdatePhotoRequest) (*dto.PhotoResponse, error) { - photo, err := s.photoRepo.FindByIDAndUserID(id, userID) + familyIDs := s.getFamilyIDs(userID) + photo, err := s.photoRepo.FindByIDAndFamilyIDs(id, familyIDs) if err != nil { return nil, fmt.Errorf("photo not found") } @@ -176,12 +206,14 @@ func (s *PhotoService) UpdatePhoto(id, userID string, req dto.UpdatePhotoRequest return nil, fmt.Errorf("failed to update photo: %w", err) } - resp := toPhotoResponse(*photo) + nicknameMap := s.buildNicknameMap(familyIDs) + resp := toPhotoResponse(*photo, nicknameMap[photo.UserID]) return &resp, nil } func (s *PhotoService) DeletePhoto(id, userID string) error { - photo, err := s.photoRepo.FindByIDAndUserID(id, userID) + familyIDs := s.getFamilyIDs(userID) + photo, err := s.photoRepo.FindByIDAndFamilyIDs(id, familyIDs) if err != nil { return fmt.Errorf("photo not found") } @@ -189,17 +221,18 @@ func (s *PhotoService) DeletePhoto(id, userID string) error { // Remove file from disk if it's a local upload fileutil.RemoveUploadedFile(photo.ImageURL) - return s.photoRepo.Delete(id, userID) + return s.photoRepo.DeleteByFamily(id, familyIDs) } func (s *PhotoService) ToggleWall(id, userID string, req dto.ToggleWallRequest) (*dto.PhotoResponse, error) { - photo, err := s.photoRepo.FindByIDAndUserID(id, userID) + familyIDs := s.getFamilyIDs(userID) + photo, err := s.photoRepo.FindByIDAndFamilyIDs(id, familyIDs) if err != nil { return nil, fmt.Errorf("photo not found") } if req.IsOnWall && !photo.IsOnWall { - wallCount, countErr := s.photoRepo.CountWallPhotos(userID) + wallCount, countErr := s.photoRepo.CountWallPhotosByFamily(familyIDs) if countErr != nil { return nil, fmt.Errorf("failed to check wall count: %w", countErr) } @@ -218,7 +251,8 @@ func (s *PhotoService) ToggleWall(id, userID string, req dto.ToggleWallRequest) return nil, fmt.Errorf("failed to update photo: %w", err) } - resp := toPhotoResponse(*photo) + nicknameMap := s.buildNicknameMap(familyIDs) + resp := toPhotoResponse(*photo, nicknameMap[photo.UserID]) return &resp, nil } @@ -227,6 +261,8 @@ func (s *PhotoService) BatchUpdateWall(userID string, req dto.BatchWallUpdateReq return nil, fmt.Errorf("too many wall photos (max %d)", maxWallPhotos) } + familyIDs := s.getFamilyIDs(userID) + updates := make([]repository.WallUpdate, 0, len(req.Photos)) for _, item := range req.Photos { updates = append(updates, repository.WallUpdate{ @@ -235,18 +271,19 @@ func (s *PhotoService) BatchUpdateWall(userID string, req dto.BatchWallUpdateReq }) } - if err := s.photoRepo.BatchUpdateWall(userID, updates); err != nil { + if err := s.photoRepo.BatchUpdateWallFamily(familyIDs, updates); err != nil { return nil, fmt.Errorf("failed to update wall: %w", err) } - wallPhotos, err := s.photoRepo.FindWallPhotos(userID) + wallPhotos, err := s.photoRepo.FindWallPhotosByFamily(familyIDs) if err != nil { return nil, fmt.Errorf("failed to fetch wall photos: %w", err) } + nicknameMap := s.buildNicknameMap(familyIDs) results := make([]dto.PhotoResponse, 0, len(wallPhotos)) for _, p := range wallPhotos { - results = append(results, toPhotoResponse(p)) + results = append(results, toPhotoResponse(p, nicknameMap[p.UserID])) } return results, nil } @@ -317,7 +354,7 @@ func (s *PhotoService) downloadFromURL(imageURL, savePath string) (string, error return "/uploads/photos/" + filepath.Base(savePath), nil } -func toPhotoResponse(p model.Photo) dto.PhotoResponse { +func toPhotoResponse(p model.Photo, ownerNickname string) dto.PhotoResponse { var tags []string if p.Tags != "" { _ = json.Unmarshal([]byte(p.Tags), &tags) @@ -327,16 +364,18 @@ func toPhotoResponse(p model.Photo) dto.PhotoResponse { } 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), + ID: p.ID, + Title: p.Title, + Description: p.Description, + Tags: tags, + ImageURL: p.ImageURL, + IsOnWall: p.IsOnWall, + WallPosition: p.WallPosition, + Source: p.Source, + OwnerID: p.UserID, + OwnerNickname: ownerNickname, + CreatedAt: p.CreatedAt.Format(time.RFC3339), + UpdatedAt: p.UpdatedAt.Format(time.RFC3339), } } @@ -350,7 +389,7 @@ func truncate(s string, maxLen int) string { // buildImagePrompt wraps the user's content description with style guidance. // Rules: -// 1. Use flat cartoon / warm illustration style +// 1. Use sticker illustration style with white border and vector feel // 2. Treat the user input as scene content, not a literal title // 3. Default protagonist gender is based on user role: dad → male, otherwise female func buildImagePrompt(userContent, userRole string) string { @@ -360,9 +399,9 @@ func buildImagePrompt(userContent, userRole string) string { } return fmt.Sprintf( - "Flat cartoon style, warm pastel color illustration, soft lighting, cozy atmosphere. "+ - "Scene description: %s. "+ + "Sticker illustration: %s. "+ "If the scene includes a person and no gender is specified, %s. "+ + "White border, vector style, high quality. "+ "No text, no watermark, no signature.", userContent, genderHint, ) diff --git a/frontend/src/components/overlay/AiMemoryPanel.vue b/frontend/src/components/overlay/AiMemoryPanel.vue index 530d63cd..8bf319c3 100644 --- a/frontend/src/components/overlay/AiMemoryPanel.vue +++ b/frontend/src/components/overlay/AiMemoryPanel.vue @@ -22,7 +22,7 @@