diff --git a/Dockerfile b/Dockerfile index af18972c..524433ad 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,7 +18,7 @@ RUN CGO_ENABLED=0 go build -o /server cmd/server/main.go # ---- Stage 3: Final image ---- FROM nginx:alpine -RUN apk --no-cache add ca-certificates tzdata +RUN apk --no-cache add ca-certificates tzdata postgresql postgresql-contrib # Nginx config COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf @@ -33,5 +33,5 @@ COPY --from=backend-builder /server /app/server COPY deploy/entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh -EXPOSE 80 +EXPOSE 7860 CMD ["/entrypoint.sh"] diff --git a/backend/internal/database/migrate.go b/backend/internal/database/migrate.go index e3c248c5..3716446e 100644 --- a/backend/internal/database/migrate.go +++ b/backend/internal/database/migrate.go @@ -18,6 +18,7 @@ func Migrate(db *gorm.DB) error { &model.Collection{}, &model.ModerationLog{}, &model.ChatMemory{}, + &model.ChatMemoryFact{}, &model.IdentityTag{}, &model.Memoir{}, &model.Photo{}, diff --git a/backend/internal/dto/chat.go b/backend/internal/dto/chat.go index 123d299f..5f01b7c0 100644 --- a/backend/internal/dto/chat.go +++ b/backend/internal/dto/chat.go @@ -27,7 +27,35 @@ type ChatProfile struct { PetDetails *string `json:"pet_details"` Interests []string `json:"interests"` Concerns []string `json:"concerns"` + Facts []string `json:"facts"` ImportantDates []string `json:"important_dates"` BabyAgeWeeks *int `json:"baby_age_weeks"` CommunityInteractions []string `json:"community_interactions"` } + +// ChatMemoryFactDTO is the response body for a single memory fact +type ChatMemoryFactDTO struct { + ID string `json:"id"` + Content string `json:"content"` + Category string `json:"category"` + CreatedAt string `json:"created_at"` + LastReferencedAt *string `json:"last_referenced_at"` +} + +// ChatMemoryFactsResponse wraps the list of memory facts +type ChatMemoryFactsResponse struct { + Facts []ChatMemoryFactDTO `json:"facts"` + Total int `json:"total"` +} + +// ConversationTurn represents a single user-assistant exchange +type ConversationTurn struct { + UserInput string `json:"user_input"` + AssistantResponse string `json:"assistant_response"` +} + +// ConversationHistoryResponse wraps conversation turns and summary +type ConversationHistoryResponse struct { + Turns []ConversationTurn `json:"turns"` + Summary string `json:"summary"` +} diff --git a/backend/internal/dto/photo.go b/backend/internal/dto/photo.go index 56c38f79..462f6733 100644 --- a/backend/internal/dto/photo.go +++ b/backend/internal/dto/photo.go @@ -33,14 +33,14 @@ type UpdatePhotoRequest struct { type ToggleWallRequest struct { IsOnWall bool `json:"is_on_wall"` - WallPosition *int `json:"wall_position" binding:"omitempty,min=0,max=8"` + WallPosition *int `json:"wall_position" binding:"omitempty,min=0,max=9"` } type BatchWallUpdateRequest struct { - Photos []WallItem `json:"photos" binding:"required,min=1,max=9"` + Photos []WallItem `json:"photos" binding:"required,min=1,max=10"` } type WallItem struct { PhotoID string `json:"photo_id" binding:"required"` - Position int `json:"position" binding:"min=0,max=8"` + Position int `json:"position" binding:"min=0,max=9"` } diff --git a/backend/internal/handler/chat.go b/backend/internal/handler/chat.go index 93ea2a8f..895e019b 100644 --- a/backend/internal/handler/chat.go +++ b/backend/internal/handler/chat.go @@ -59,3 +59,47 @@ func (h *ChatHandler) GetProfile(c *gin.Context) { profile := h.chatService.GetGuestProfile(sessionID) c.JSON(http.StatusOK, profile) } + +// GET /api/v1/companion/memories +func (h *ChatHandler) GetMemories(c *gin.Context) { + userID := middleware.GetUserID(c) + resp, err := h.chatService.GetMemories(userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, resp) +} + +// GET /api/v1/companion/history +func (h *ChatHandler) GetHistory(c *gin.Context) { + userID := middleware.GetUserID(c) + resp, err := h.chatService.GetConversationHistory(userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, resp) +} + +// DELETE /api/v1/companion/history +func (h *ChatHandler) ClearHistory(c *gin.Context) { + userID := middleware.GetUserID(c) + if err := h.chatService.ClearConversationHistory(userID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "对话历史已清除"}) +} + +// DELETE /api/v1/companion/memories/:id +func (h *ChatHandler) DeleteMemory(c *gin.Context) { + userID := middleware.GetUserID(c) + factID := c.Param("id") + + if err := h.chatService.DeleteMemory(userID, factID); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "已删除"}) +} diff --git a/backend/internal/model/chat.go b/backend/internal/model/chat.go index e59abb23..46a80a32 100644 --- a/backend/internal/model/chat.go +++ b/backend/internal/model/chat.go @@ -9,10 +9,11 @@ import ( ) type ChatMemory struct { - ID string `gorm:"type:varchar(36);primaryKey" json:"id"` - UserID string `gorm:"type:varchar(36);uniqueIndex;not null" json:"user_id"` - ProfileData string `gorm:"type:text" json:"profile_data"` // JSON - ConversationTurns string `gorm:"type:text" json:"conversation_turns"` // JSON array + ID string `gorm:"type:varchar(36);primaryKey" json:"id"` + UserID string `gorm:"type:varchar(36);uniqueIndex;not null" json:"user_id"` + ProfileData string `gorm:"type:text" json:"profile_data"` // JSON + ConversationTurns string `gorm:"type:text" json:"conversation_turns"` // JSON array + ConversationSummary string `gorm:"type:text" json:"conversation_summary"` // compressed old turns CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -20,6 +21,36 @@ type ChatMemory struct { User User `gorm:"foreignKey:UserID" json:"-"` } +type FactCategory string + +const ( + FactCategoryPersonalInfo FactCategory = "personal_info" + FactCategoryFamily FactCategory = "family" + FactCategoryInterest FactCategory = "interest" + FactCategoryConcern FactCategory = "concern" + FactCategoryPreference FactCategory = "preference" + FactCategoryOther FactCategory = "other" +) + +type ChatMemoryFact struct { + ID string `gorm:"type:varchar(36);primaryKey" json:"id"` + UserID string `gorm:"type:varchar(36);index;not null" json:"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"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + LastReferencedAt *time.Time `json:"last_referenced_at"` + + User User `gorm:"foreignKey:UserID" json:"-"` +} + +func (f *ChatMemoryFact) BeforeCreate(tx *gorm.DB) error { + if f.ID == "" { + f.ID = uuid.New().String() + } + return nil +} + func (m *ChatMemory) BeforeCreate(tx *gorm.DB) error { if m.ID == "" { m.ID = uuid.New().String() @@ -58,3 +89,11 @@ func (m *ChatMemory) SetTurns(turns []map[string]interface{}) { b, _ := json.Marshal(turns) m.ConversationTurns = string(b) } + +func (m *ChatMemory) GetSummary() string { + return m.ConversationSummary +} + +func (m *ChatMemory) SetSummary(summary string) { + m.ConversationSummary = summary +} diff --git a/backend/internal/repository/chat.go b/backend/internal/repository/chat.go index 7721d7c6..621eeb2a 100644 --- a/backend/internal/repository/chat.go +++ b/backend/internal/repository/chat.go @@ -26,10 +26,62 @@ func (r *ChatRepo) FindByUserID(userID string) (*model.ChatMemory, error) { func (r *ChatRepo) Upsert(m *model.ChatMemory) error { return r.db.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "user_id"}}, - DoUpdates: clause.AssignmentColumns([]string{"profile_data", "conversation_turns", "updated_at"}), + DoUpdates: clause.AssignmentColumns([]string{"profile_data", "conversation_turns", "conversation_summary", "updated_at"}), }).Create(m).Error } func (r *ChatRepo) Create(m *model.ChatMemory) error { return r.db.Create(m).Error } + +// UpdateSummaryAndTurns updates only the summary and turns fields. +func (r *ChatRepo) UpdateSummaryAndTurns(userID string, summary string, turns string) error { + return r.db.Model(&model.ChatMemory{}). + Where("user_id = ?", userID). + Updates(map[string]any{ + "conversation_summary": summary, + "conversation_turns": turns, + }).Error +} + +// --- ChatMemoryFact methods --- + +func (r *ChatRepo) FindFactsByUserID(userID string) ([]model.ChatMemoryFact, error) { + var facts []model.ChatMemoryFact + err := r.db.Where("user_id = ?", userID).Order("created_at desc").Find(&facts).Error + return facts, err +} + +func (r *ChatRepo) FindFactByID(id string) (*model.ChatMemoryFact, error) { + var f model.ChatMemoryFact + err := r.db.Where("id = ?", id).First(&f).Error + if err != nil { + return nil, err + } + return &f, nil +} + +func (r *ChatRepo) CreateFact(f *model.ChatMemoryFact) error { + return r.db.Create(f).Error +} + +func (r *ChatRepo) DeleteFact(id string) error { + return r.db.Where("id = ?", id).Delete(&model.ChatMemoryFact{}).Error +} + +func (r *ChatRepo) FactExistsByContent(userID, content string) (bool, error) { + var count int64 + err := r.db.Unscoped().Model(&model.ChatMemoryFact{}). + Where("user_id = ? AND content = ?", userID, content). + Count(&count).Error + return count > 0, err +} + +func (r *ChatRepo) TouchFactReferencedAt(ids []string) error { + if len(ids) == 0 { + return nil + } + return r.db.Model(&model.ChatMemoryFact{}). + Where("id IN ?", ids). + Update("last_referenced_at", gorm.Expr("NOW()")).Error +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 9a2fe785..6108d6f1 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -146,6 +146,10 @@ func Setup( { companion.POST("/chat", aiLimiter, middleware.AuthOptional(cfg), chatHandler.Chat) companion.GET("/profile", middleware.AuthOptional(cfg), chatHandler.GetProfile) + companion.GET("/memories", middleware.AuthRequired(cfg), chatHandler.GetMemories) + companion.DELETE("/memories/:id", middleware.AuthRequired(cfg), chatHandler.DeleteMemory) + companion.GET("/history", middleware.AuthRequired(cfg), chatHandler.GetHistory) + companion.DELETE("/history", middleware.AuthRequired(cfg), chatHandler.ClearHistory) } echo := api.Group("/echo", middleware.AuthRequired(cfg)) diff --git a/backend/internal/service/chat.go b/backend/internal/service/chat.go index c05644b6..4aa4bc44 100644 --- a/backend/internal/service/chat.go +++ b/backend/internal/service/chat.go @@ -6,8 +6,10 @@ import ( "fmt" "log" "regexp" + "sort" "strings" "sync" + "time" "github.com/google/uuid" "github.com/momshell/backend/internal/dto" @@ -50,7 +52,7 @@ 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**: 如果用户分享了值得记住的信息,提取出来;否则为 null +3. **memory_extract**: 如果用户分享了值得记住的信息,提取为 JSON 对象(包含 facts 字符串数组,每条用一句话概括一个重要信息,如"宝宝叫小米"、"最近在学烘焙"、"老公经常出差");否则为 null 记住:你的存在不是为了「解决她的问题」,而是让她感到——在这一刻,有人真正看见了她。` @@ -87,7 +89,7 @@ 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**: 如果用户分享了值得记住的信息,提取出来;否则为 null +3. **memory_extract**: 如果用户分享了值得记住的信息,提取为 JSON 对象(包含 facts 字符串数组,每条用一句话概括一个重要信息,如"宝宝叫小米"、"最近在学烘焙"、"老公经常出差");否则为 null 记住:你的存在是帮他成为更好的自己——看清方向,迈出下一步。` @@ -124,12 +126,31 @@ 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**: 如果用户分享了值得记住的信息,提取出来;否则为 null +3. **memory_extract**: 如果用户分享了值得记住的信息,提取为 JSON 对象(包含 facts 字符串数组,每条用一句话概括一个重要信息,如"宝宝叫小米"、"最近在学烘焙"、"老公经常出差");否则为 null 记住:你的存在是提供一个对等的、可以卸下专业面具的空间。` const adminPromptSuffix = "\n\n## 额外信息\n该用户是社区管理员。保持一贯的真诚态度,涉及社区管理话题时可以更直接高效地交流。" +const summarizationPrompt = `请将以下对话历史压缩为一段简洁的中文摘要(不超过500字)。 +保留关键信息:用户提到的重要事件、情感变化、做出的决定、讨论的话题。 +删除重复和琐碎内容。如果已有旧摘要,将新内容与旧摘要合并。 + +已有摘要: +%s + +新增对话: +%s + +请直接输出合并后的完整摘要(纯文本,不要JSON格式,不要任何前缀说明)。` + +const ( + maxGuestSessions = 1000 + summaryThreshold = 20 // trigger summarization when turns exceed this + keepRecentTurns = 15 // keep this many recent turns after summarization + promptTurns = 10 // inject this many turns into the prompt +) + func getCompanionPrompt(role model.UserRole, isAdmin bool) string { var prompt string if model.ProfessionalRoles[role] { @@ -155,29 +176,27 @@ func pronounFor(role model.UserRole) string { return "她" } -const ( - maxGuestSessions = 1000 // Maximum number of guest sessions in memory -) - type ChatService struct { client *openai.Client chatRepo *repository.ChatRepo userRepo *repository.UserRepo firecrawl *firecrawl.Client // In-memory storage for guest sessions - mu sync.RWMutex - guestMemory map[string][]map[string]interface{} - guestProfiles map[string]map[string]interface{} + mu sync.RWMutex + guestMemory map[string][]map[string]interface{} + guestProfiles map[string]map[string]interface{} + guestLastAccess map[string]time.Time } func NewChatService(client *openai.Client, chatRepo *repository.ChatRepo, userRepo *repository.UserRepo, fc *firecrawl.Client) *ChatService { return &ChatService{ - client: client, - chatRepo: chatRepo, - userRepo: userRepo, - firecrawl: fc, - guestMemory: make(map[string][]map[string]interface{}), - guestProfiles: make(map[string]map[string]interface{}), + client: client, + chatRepo: chatRepo, + userRepo: userRepo, + firecrawl: fc, + guestMemory: make(map[string][]map[string]interface{}), + guestProfiles: make(map[string]map[string]interface{}), + guestLastAccess: make(map[string]time.Time), } } @@ -199,11 +218,14 @@ func (s *ChatService) chatAuthenticated(ctx context.Context, msg dto.UserMessage pronoun := pronounFor(role) // Load memory from DB - profile, turns := s.loadUserMemory(userID) + profile, turns, summary := s.loadUserMemory(userID) + + // Load structured facts for prompt + factsText := s.loadFactsForPrompt(userID) systemPrompt := fmt.Sprintf(getCompanionPrompt(role, isAdmin), - formatProfile(profile, pronoun), - formatTurns(turns, pronoun), + formatProfile(profile, pronoun, factsText), + formatTurns(turns, summary, pronoun), ) webResults := s.searchWebForChat(ctx, msg.Content) @@ -223,20 +245,27 @@ func (s *ChatService) chatAuthenticated(ctx context.Context, msg dto.UserMessage parsed := parseLLMResponse(rawContent) - // Update memory + // Update profile from extract memoryUpdated := updateProfileFromExtract(profile, parsed["memory_extract"]) - // Save turn + // Save structured facts (Phase 3) + s.saveFactsFromExtract(userID, parsed["memory_extract"]) + + // Append new turn turns = append(turns, map[string]interface{}{ "user_input": msg.Content, "assistant_response": parsed["text"], }) - if len(turns) > 20 { - turns = turns[len(turns)-20:] + + // Phase 2: trigger summarization if turns exceed threshold + if len(turns) > summaryThreshold { + toSummarize := turns[:len(turns)-keepRecentTurns] + turns = turns[len(turns)-keepRecentTurns:] + go s.generateAndSaveSummary(userID, summary, toSummarize) } // Save to DB - s.saveUserMemory(userID, profile, turns) + s.saveUserMemory(userID, profile, turns, summary) return buildVisualResponse(parsed, memoryUpdated), nil } @@ -252,33 +281,40 @@ func (s *ChatService) chatGuest(ctx context.Context, msg dto.UserMessage) (*dto. s.mu.Lock() if _, ok := s.guestMemory[sessionID]; !ok { - // Evict oldest sessions if at capacity + // Evict least recently accessed sessions if at capacity if len(s.guestMemory) >= maxGuestSessions { - // Delete ~10% of sessions to avoid evicting on every new request toDelete := maxGuestSessions / 10 if toDelete < 1 { toDelete = 1 } - deleted := 0 - for k := range s.guestMemory { - delete(s.guestMemory, k) - delete(s.guestProfiles, k) - deleted++ - if deleted >= toDelete { - break - } + type sessionAge struct { + id string + ts time.Time + } + ages := make([]sessionAge, 0, len(s.guestLastAccess)) + for k, t := range s.guestLastAccess { + ages = append(ages, sessionAge{k, t}) + } + sort.Slice(ages, func(i, j int) bool { + return ages[i].ts.Before(ages[j].ts) + }) + for i := 0; i < toDelete && i < len(ages); i++ { + delete(s.guestMemory, ages[i].id) + delete(s.guestProfiles, ages[i].id) + delete(s.guestLastAccess, ages[i].id) } } s.guestMemory[sessionID] = nil s.guestProfiles[sessionID] = make(map[string]interface{}) } + s.guestLastAccess[sessionID] = time.Now() profile := s.guestProfiles[sessionID] turns := s.guestMemory[sessionID] s.mu.Unlock() systemPrompt := fmt.Sprintf(getCompanionPrompt(model.RoleMom, false), - formatProfile(profile, "她"), - formatTurns(turns, "她"), + formatProfile(profile, "她", ""), + formatTurns(turns, "", "她"), ) webResults := s.searchWebForChat(ctx, msg.Content) @@ -303,8 +339,8 @@ func (s *ChatService) chatGuest(ctx context.Context, msg dto.UserMessage) (*dto. "user_input": msg.Content, "assistant_response": parsed["text"], }) - if len(turns) > 20 { - turns = turns[len(turns)-20:] + if len(turns) > summaryThreshold { + turns = turns[len(turns)-keepRecentTurns:] } s.mu.Lock() @@ -315,6 +351,213 @@ func (s *ChatService) chatGuest(ctx context.Context, msg dto.UserMessage) (*dto. return buildVisualResponse(parsed, memoryUpdated), nil } +// --- Phase 2: Conversation Summary --- + +func (s *ChatService) generateAndSaveSummary(userID string, existingSummary string, oldTurns []map[string]interface{}) { + if len(oldTurns) == 0 { + return + } + + // Format old turns for summarization + var sb strings.Builder + for _, t := range oldTurns { + fmt.Fprintf(&sb, "用户:%v\n回复:%v\n\n", t["user_input"], t["assistant_response"]) + } + + oldSummaryText := existingSummary + if oldSummaryText == "" { + oldSummaryText = "(无)" + } + + prompt := fmt.Sprintf(summarizationPrompt, oldSummaryText, sb.String()) + messages := []openai.Message{ + {Role: "system", Content: "你是一个对话摘要助手。"}, + {Role: "user", Content: prompt}, + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + newSummary, err := s.client.Chat(ctx, messages) + if err != nil { + log.Printf("[ChatService] failed to generate summary for user %s: %v", userID, err) + return + } + + newSummary = strings.TrimSpace(newSummary) + + // Load current memory to get latest turns (may have changed since goroutine started) + mem, err := s.chatRepo.FindByUserID(userID) + if err != nil { + log.Printf("[ChatService] failed to load memory for summary update user %s: %v", userID, err) + return + } + + mem.SetSummary(newSummary) + if err := s.chatRepo.UpdateSummaryAndTurns(userID, newSummary, mem.ConversationTurns); err != nil { + log.Printf("[ChatService] failed to save summary for user %s: %v", userID, err) + } +} + +// --- Phase 3: Structured Memory Facts --- + +func (s *ChatService) saveFactsFromExtract(userID string, extract interface{}) { + if extract == nil { + return + } + extractMap, ok := extract.(map[string]interface{}) + if !ok { + return + } + facts, ok := extractMap["facts"].([]interface{}) + if !ok || len(facts) == 0 { + return + } + + for _, v := range facts { + content, ok := v.(string) + if !ok || strings.TrimSpace(content) == "" { + continue + } + content = strings.TrimSpace(content) + + // Skip if identical fact already exists + exists, err := s.chatRepo.FactExistsByContent(userID, content) + if err != nil { + log.Printf("[ChatService] failed to check fact existence: %v", err) + continue + } + if exists { + continue + } + + category := categorizeFactContent(content) + fact := &model.ChatMemoryFact{ + UserID: 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) + } + } +} + +func categorizeFactContent(content string) model.FactCategory { + lower := strings.ToLower(content) + switch { + 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, "婆婆"): + 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, "住在"): + return model.FactCategoryPersonalInfo + case strings.Contains(lower, "偏好") || strings.Contains(lower, "习惯") || + strings.Contains(lower, "不喜欢"): + return model.FactCategoryPreference + default: + return model.FactCategoryOther + } +} + +func (s *ChatService) loadFactsForPrompt(userID string) string { + facts, err := s.chatRepo.FindFactsByUserID(userID) + if err != nil || len(facts) == 0 { + return "" + } + + // Track referenced fact IDs to update last_referenced_at + ids := make([]string, 0, len(facts)) + + var sb strings.Builder + for _, f := range facts { + 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) + } + }() + + return sb.String() +} + +// GetMemories returns all structured memory facts for a user (Phase 3 API). +func (s *ChatService) GetMemories(userID string) (*dto.ChatMemoryFactsResponse, error) { + facts, err := s.chatRepo.FindFactsByUserID(userID) + if err != nil { + return &dto.ChatMemoryFactsResponse{Facts: []dto.ChatMemoryFactDTO{}, Total: 0}, nil + } + + items := make([]dto.ChatMemoryFactDTO, 0, len(facts)) + for _, f := range facts { + item := dto.ChatMemoryFactDTO{ + ID: f.ID, + Content: f.Content, + Category: string(f.Category), + CreatedAt: f.CreatedAt.Format(time.RFC3339), + } + if f.LastReferencedAt != nil { + t := f.LastReferencedAt.Format(time.RFC3339) + item.LastReferencedAt = &t + } + items = append(items, item) + } + + return &dto.ChatMemoryFactsResponse{Facts: items, Total: len(items)}, nil +} + +// DeleteMemory deletes a single memory fact, verifying 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 { + return fmt.Errorf("无权删除此记忆") + } + return s.chatRepo.DeleteFact(factID) +} + +// GetConversationHistory returns the user's conversation turns and summary. +func (s *ChatService) GetConversationHistory(userID string) (*dto.ConversationHistoryResponse, error) { + _, turns, summary := s.loadUserMemory(userID) + + dtoTurns := make([]dto.ConversationTurn, 0, len(turns)) + for _, t := range turns { + userInput, _ := t["user_input"].(string) + assistantResp, _ := t["assistant_response"].(string) + dtoTurns = append(dtoTurns, dto.ConversationTurn{ + UserInput: userInput, + AssistantResponse: assistantResp, + }) + } + + return &dto.ConversationHistoryResponse{ + Turns: dtoTurns, + Summary: summary, + }, nil +} + +// ClearConversationHistory resets the user's conversation turns and summary. +func (s *ChatService) ClearConversationHistory(userID string) error { + return s.chatRepo.UpdateSummaryAndTurns(userID, "", "[]") +} + +// --- Existing helpers (updated) --- + func (s *ChatService) searchWebForChat(ctx context.Context, userMessage string) string { if s.firecrawl == nil { return "" @@ -347,6 +590,7 @@ func (s *ChatService) GetProfile(userID string) (*dto.ChatProfile, error) { return &dto.ChatProfile{ Interests: []string{}, Concerns: []string{}, + Facts: []string{}, ImportantDates: []string{}, CommunityInteractions: []string{}, }, nil @@ -364,6 +608,7 @@ func (s *ChatService) GetGuestProfile(sessionID string) *dto.ChatProfile { return &dto.ChatProfile{ Interests: []string{}, Concerns: []string{}, + Facts: []string{}, ImportantDates: []string{}, CommunityInteractions: []string{}, } @@ -371,28 +616,29 @@ func (s *ChatService) GetGuestProfile(sessionID string) *dto.ChatProfile { return profileToDTO(profile) } -func (s *ChatService) loadUserMemory(userID string) (map[string]interface{}, []map[string]interface{}) { +func (s *ChatService) loadUserMemory(userID string) (map[string]interface{}, []map[string]interface{}, string) { mem, err := s.chatRepo.FindByUserID(userID) if err != nil { - return make(map[string]interface{}), nil + return make(map[string]interface{}), nil, "" } - return mem.GetProfile(), mem.GetTurns() + return mem.GetProfile(), mem.GetTurns(), mem.GetSummary() } -func (s *ChatService) saveUserMemory(userID string, profile map[string]interface{}, turns []map[string]interface{}) { +func (s *ChatService) saveUserMemory(userID string, profile map[string]interface{}, turns []map[string]interface{}, summary string) { mem := &model.ChatMemory{ UserID: userID, } mem.SetProfile(profile) mem.SetTurns(turns) + mem.SetSummary(summary) if err := s.chatRepo.Upsert(mem); err != nil { log.Printf("[ChatService] failed to save memory for user %s: %v", userID, err) } } -func formatProfile(profile map[string]interface{}, pronoun string) string { - if len(profile) == 0 { +func formatProfile(profile map[string]interface{}, pronoun string, factsText string) string { + if len(profile) == 0 && factsText == "" { return "(暂无记录)" } parts := "" @@ -404,6 +650,18 @@ func formatProfile(profile map[string]interface{}, pronoun string) string { parts += fmt.Sprintf("- %s有宠物:%s\n", pronoun, details) } } + + // Structured facts from DB (Phase 3) take priority + 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 { parts += fmt.Sprintf("- %s的兴趣:", pronoun) for i, v := range interests { @@ -430,17 +688,27 @@ func formatProfile(profile map[string]interface{}, pronoun string) string { return parts } -func formatTurns(turns []map[string]interface{}, pronoun string) string { - if len(turns) == 0 { +func formatTurns(turns []map[string]interface{}, summary string, pronoun string) string { + if len(turns) == 0 && summary == "" { return "(这是你们的第一次对话)" } - result := "" + var result string + + // Prepend summary of older conversations (Phase 2) + if summary != "" { + result += "【earlier conversation summary】\n" + summary + "\n\n【recent conversations】\n" + } + start := 0 - if len(turns) > 5 { - start = len(turns) - 5 + if len(turns) > promptTurns { + start = len(turns) - promptTurns } for _, t := range turns[start:] { - result += fmt.Sprintf("%s说:%v\n你回复:%v\n", pronoun, t["user_input"], t["assistant_response"]) + response := fmt.Sprintf("%v", t["assistant_response"]) + if len([]rune(response)) > 200 { + response = string([]rune(response)[:200]) + "..." + } + result += fmt.Sprintf("%s说:%v\n你回复:%s\n", pronoun, t["user_input"], response) } return result } @@ -505,18 +773,46 @@ func updateProfileFromExtract(profile map[string]interface{}, extract interface{ } if interests, ok := extractMap["interests"].([]interface{}); ok && len(interests) > 0 { existing, _ := profile["interests"].([]interface{}) - profile["interests"] = append(existing, interests...) + profile["interests"] = deduplicateAndCap(existing, interests, 20) updated = true } if concerns, ok := extractMap["concerns"].([]interface{}); ok && len(concerns) > 0 { existing, _ := profile["concerns"].([]interface{}) - profile["concerns"] = append(existing, concerns...) + 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 } +func deduplicateAndCap(existing, newItems []interface{}, maxItems int) []interface{} { + seen := make(map[string]bool) + var result []interface{} + for _, v := range existing { + s := fmt.Sprintf("%v", v) + if !seen[s] { + seen[s] = true + result = append(result, v) + } + } + for _, v := range newItems { + s := fmt.Sprintf("%v", v) + if !seen[s] { + seen[s] = true + result = append(result, v) + } + } + if len(result) > maxItems { + result = result[len(result)-maxItems:] + } + return result +} + func buildVisualResponse(parsed map[string]interface{}, memoryUpdated bool) *dto.VisualResponse { text := "我在这里陪着你。" if t, ok := parsed["text"].(string); ok && t != "" { @@ -552,6 +848,7 @@ func profileToDTO(profile map[string]interface{}) *dto.ChatProfile { cp := &dto.ChatProfile{ Interests: []string{}, Concerns: []string{}, + Facts: []string{}, ImportantDates: []string{}, CommunityInteractions: []string{}, } @@ -579,6 +876,13 @@ func profileToDTO(profile map[string]interface{}) *dto.ChatProfile { } } } + if facts, ok := profile["facts"].([]interface{}); ok { + for _, v := range facts { + if s, ok := v.(string); ok { + cp.Facts = append(cp.Facts, s) + } + } + } if dates, ok := profile["important_dates"].([]interface{}); ok { for _, v := range dates { if s, ok := v.(string); ok { diff --git a/backend/internal/service/photo.go b/backend/internal/service/photo.go index 153d0376..2af6ff98 100644 --- a/backend/internal/service/photo.go +++ b/backend/internal/service/photo.go @@ -25,7 +25,7 @@ import ( const ( maxPhotosPerUser = 25 - maxWallPhotos = 9 + maxWallPhotos = 10 ) type PhotoService struct { diff --git a/deploy/entrypoint.sh b/deploy/entrypoint.sh index e080c9a8..5ce38e92 100755 --- a/deploy/entrypoint.sh +++ b/deploy/entrypoint.sh @@ -1,6 +1,32 @@ #!/bin/sh set -e +PG_DATA="/var/lib/postgresql/data" +PG_USER="${POSTGRES_USER:-momshell}" +PG_PASS="${POSTGRES_PASSWORD:-momshell}" +PG_DB="${POSTGRES_DB:-momshell}" + +# Initialize PostgreSQL if needed +if [ ! -s "$PG_DATA/PG_VERSION" ]; then + mkdir -p "$PG_DATA" + chown postgres:postgres "$PG_DATA" + su postgres -c "initdb -D $PG_DATA --no-locale --encoding=UTF8" + # Allow local connections without password + md5 for TCP + echo "local all all trust" > "$PG_DATA/pg_hba.conf" + echo "host all all 127.0.0.1/32 md5" >> "$PG_DATA/pg_hba.conf" + echo "host all all ::1/128 md5" >> "$PG_DATA/pg_hba.conf" +fi + +# Start PostgreSQL +su postgres -c "pg_ctl -D $PG_DATA -l /var/log/postgresql.log start -w" + +# Create user and database if they don't exist +su postgres -c "psql -tc \"SELECT 1 FROM pg_roles WHERE rolname='$PG_USER'\" | grep -q 1 || psql -c \"CREATE USER $PG_USER WITH PASSWORD '$PG_PASS'\"" +su postgres -c "psql -tc \"SELECT 1 FROM pg_catalog.pg_database WHERE datname='$PG_DB'\" | grep -q 1 || psql -c \"CREATE DATABASE $PG_DB OWNER $PG_USER\"" + +# Set DATABASE_URL if not already set +export DATABASE_URL="${DATABASE_URL:-postgres://$PG_USER:$PG_PASS@localhost:5432/$PG_DB?sslmode=disable}" + # Start backend in background /app/server & diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 7909d396..646fc097 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -1,5 +1,5 @@ server { - listen 80; + listen 7860; server_name _; root /usr/share/nginx/html; index index.html; @@ -35,4 +35,11 @@ server { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } + + # proxy uploads to backend + location /uploads/ { + proxy_pass http://127.0.0.1:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } } diff --git a/frontend/src/App.vue b/frontend/src/App.vue index d08cbc1d..8d806bb1 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -8,6 +8,7 @@ + @@ -24,6 +25,7 @@ import MemoryPanel from '@/components/overlay/MemoryPanel.vue' import CommunityPanel from '@/components/overlay/CommunityPanel.vue' import BarPage from '@/components/overlay/BarPage.vue' import ChatPanel from '@/components/overlay/ChatPanel.vue' +import AiMemoryPanel from '@/components/overlay/AiMemoryPanel.vue' import WhisperPanel from '@/components/overlay/WhisperPanel.vue' import TaskPanel from '@/components/overlay/TaskPanel.vue' import { useAuthStore } from '@/stores/auth' diff --git a/frontend/src/components/overlay/AiMemoryPanel.vue b/frontend/src/components/overlay/AiMemoryPanel.vue new file mode 100644 index 00000000..530d63cd --- /dev/null +++ b/frontend/src/components/overlay/AiMemoryPanel.vue @@ -0,0 +1,564 @@ + + + + + diff --git a/frontend/src/components/overlay/CarPage.vue b/frontend/src/components/overlay/CarPage.vue index c16aa635..ddd5bada 100644 --- a/frontend/src/components/overlay/CarPage.vue +++ b/frontend/src/components/overlay/CarPage.vue @@ -66,8 +66,8 @@ - -