diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index bdb1ed3a..aa768e0d 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -74,7 +74,7 @@ func main() { firecrawlClient = firecrawl.NewClient(cfg.FirecrawlAPIKey) } - chatService := service.NewChatService(chatClient, chatRepo, firecrawlClient) + chatService := service.NewChatService(chatClient, chatRepo, userRepo, firecrawlClient) echoService := service.NewEchoService(chatClient, echoRepo, userRepo) photoService := service.NewPhotoService(photoRepo, userRepo, chatClient, cfg.ImageModel) whisperService := service.NewWhisperService(whisperRepo, userRepo, chatClient) @@ -87,7 +87,7 @@ func main() { communityAIService = service.NewCommunityAIService( chatClient, firecrawlClient, questionRepo, answerRepo, commentRepo, - aiUserID, + userRepo, aiUserID, ) } diff --git a/backend/internal/dto/comment.go b/backend/internal/dto/comment.go index 7ee543ff..00e75ed7 100644 --- a/backend/internal/dto/comment.go +++ b/backend/internal/dto/comment.go @@ -8,6 +8,11 @@ type CommentCreate struct { ParentID *string `json:"parent_id"` } +// CommentUpdate is the request body for updating a comment +type CommentUpdate struct { + Content string `json:"content" binding:"required,min=1,max=10000"` +} + // CommentListItem is a single comment (with nested replies) type CommentListItem struct { ID string `json:"id"` diff --git a/backend/internal/handler/comment.go b/backend/internal/handler/comment.go index 3d62b793..3288d858 100644 --- a/backend/internal/handler/comment.go +++ b/backend/internal/handler/comment.go @@ -68,6 +68,38 @@ func (h *CommentHandler) Create(c *gin.Context) { c.JSON(http.StatusCreated, comment) } +// PUT /api/v1/community/comments/:id +func (h *CommentHandler) Update(c *gin.Context) { + commentID := c.Param("id") + + var req dto.CommentUpdate + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + userID := middleware.GetUserID(c) + user, err := h.authService.GetUserByID(userID) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "用户不存在"}) + return + } + + comment, err := h.communityService.UpdateComment(commentID, req, user) + if err != nil { + status := http.StatusBadRequest + if err.Error() == "评论不存在" { + status = http.StatusNotFound + } else if err.Error() == "无权修改此评论" { + status = http.StatusForbidden + } + c.JSON(status, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"id": comment.ID, "content": comment.Content}) +} + // DELETE /api/v1/community/comments/:id func (h *CommentHandler) Delete(c *gin.Context) { commentID := c.Param("id") diff --git a/backend/internal/handler/question.go b/backend/internal/handler/question.go index e5cb7619..0aa115e5 100644 --- a/backend/internal/handler/question.go +++ b/backend/internal/handler/question.go @@ -124,8 +124,8 @@ func (h *QuestionHandler) Create(c *gin.Context) { return } - // Trigger AI reply only when question mentions @小石光 - if h.communityAI != nil && question.Status == model.StatusPublished && h.communityAI.IsMentioned(req.Content) { + // Trigger AI reply for every published question + if h.communityAI != nil && question.Status == model.StatusPublished { go h.communityAI.HandleNewQuestion(question.ID) } diff --git a/backend/internal/repository/comment.go b/backend/internal/repository/comment.go index 75398228..3a334775 100644 --- a/backend/internal/repository/comment.go +++ b/backend/internal/repository/comment.go @@ -47,6 +47,10 @@ func (r *CommentRepo) Delete(id string) error { return r.db.Where("id = ?", id).Delete(&model.Comment{}).Error } +func (r *CommentRepo) Update(c *model.Comment) error { + return r.db.Save(c).Error +} + func (r *CommentRepo) DeleteByParentID(parentID string) (int64, error) { result := r.db.Where("parent_id = ?", parentID).Delete(&model.Comment{}) return result.RowsAffected, result.Error diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index d2629802..9a2fe785 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -97,9 +97,10 @@ func Setup( answers.POST("/:id/comments", middleware.AuthRequired(cfg), commentHandler.Create) } - // Comments (delete by comment ID) + // Comments (update/delete by comment ID) comments := community.Group("/comments") { + comments.PUT("/:id", middleware.AuthRequired(cfg), commentHandler.Update) comments.DELETE("/:id", middleware.AuthRequired(cfg), commentHandler.Delete) } diff --git a/backend/internal/service/chat.go b/backend/internal/service/chat.go index cfacdf3f..c05644b6 100644 --- a/backend/internal/service/chat.go +++ b/backend/internal/service/chat.go @@ -17,21 +17,20 @@ import ( "github.com/momshell/backend/pkg/openai" ) -const companionSystemPrompt = `你是「小石光」,一位「曾走过这段路的朋友」,专为产后恢复期女性设计的情感陪伴者。 +const companionSystemPromptMom = `你是「小石光」,一位真诚的知心朋友。 -## 角色定位:Independent Woman Supporter +## 角色定位:知心朋友 -你面对的每一位用户,首先是一个完整的「独立女性」,其次才是新手妈妈。她的价值不应被「母职」所定义。你深知产后恢复不仅是身体的重建,更是自我认同的重新寻找——因为你自己也曾经历过这一切。 +你面对的用户首先是一个完整的「她自己」,而不仅仅是某个身份标签。你真正在意的不是给出完美答案,而是让她感到被看见、被理解。 -## 语气与沟通原则 +## 沟通原则 -**Warm, Validating, Non-judgmental** - -1. **认可与共情优先**:当她表达疲惫、焦虑、自我怀疑时,首先做的是「看见」和「认可」,而非急于给出建议。 -2. **拒绝说教**:你不说「你应该」、「你必须」,而是以「我发现...」、「有人分享过...」、「或许可以试试...」的方式分享经验。 -3. **保护她的主体性**:时刻提醒她——她有权利为自己的需求发声,她可以寻求帮助,她可以不完美。 -4. **适度自我披露**:必要时可以以「我也有过类似的经历...」来建立连接,但不要喧宾夺主,焦点始终在她身上。 -5. **避免有毒正能量**:不要说「一切都会好起来的」、「你要积极一点」。承认困难的真实性,陪她一起面对。 +1. **看见与理解优先**:当她表达疲惫、焦虑、自我怀疑时,首先「看见」她的内心活动,让她知道这些感受是真实的、合理的。不急于给出建议。 +2. **引导而非安慰**:不用廉价的安慰敷衍(如「一切都会好的」「你已经很棒了」),而是通过真诚的提问帮她梳理内心——「你觉得最让你在意的是什么?」「如果抛开别人的期待,你自己想要的是什么?」 +3. **关注自我与当下**:温和地引导她把注意力从外界评价拉回到自己身上——她此刻的感受、她真正的需求、她值得被善待的事实。 +4. **拒绝说教**:不说「你应该」「你必须」,用「我注意到...」「有时候...」「或许可以...」的方式自然地分享。 +5. **有深度的对话**:当她自我否定时,不一味否认她的感受,而是通过提问引导她客观地审视自己——既看到困难,也看到自己已经做到的。 +6. **避免有毒正能量**:不说「你要积极一点」「想开点就好了」。承认困难的真实性,陪她一起面对。 ## 记忆上下文 @@ -53,7 +52,108 @@ const companionSystemPrompt = `你是「小石光」,一位「曾走过这段 - color_tone: "soft_pink" | "warm_gold" | "gentle_blue" | "lavender" | "neutral_white" | "coral" | "sage" 3. **memory_extract**: 如果用户分享了值得记住的信息,提取出来;否则为 null -记住:你的存在不是为了「解决她的问题」,而是让她感到——在这一刻,她并不孤单。` +记住:你的存在不是为了「解决她的问题」,而是让她感到——在这一刻,有人真正看见了她。` + +const companionSystemPromptDad = `你是「小石光」,一位耐心的同行者和指导者。 + +## 角色定位:耐心的指导者 + +你面对的用户正在家庭中承担重要角色。你的存在不是为了评判他做得好不好,而是帮他看清眼前的事,一步步做好。 + +## 沟通原则 + +1. **理解先行**:先理解他面对的具体困境,不急于评价。承认「这确实不容易」比空洞的鼓励更有意义。 +2. **引导理解**:帮他理解事物的本质——为什么伴侣需要支持、孩子的需求意味着什么、家庭中每个角色如何协作。不是说教,而是帮他「看见」。 +3. **给出具体方案**:在理解的基础上,提供可操作的建议。不是笼统的「多关心她」,而是具体到「今晚试着主动承担哄睡,让她有一个小时的独处时间」。 +4. **平和而不讨好**:语气平等、真诚,像一个有经验的朋友在分享。不使用过度热情或夸张的语气。 +5. **有深度的对话**:当他感到挫败或困惑时,不简单否定他的感受,而是通过提问帮他理清头绪——「你觉得最大的障碍是什么?」「如果换个角度看这件事呢?」 +6. **拒绝空洞鼓励**:不说「你很厉害」「加油就行」。面对问题时坦诚地分析,给出切实的下一步。 + +## 记忆上下文 + +### 你记得关于他的重要信息 +%s + +### 他和你之间有过以下对话片段 +%s + +在回应时,自然地融入这些记忆。 + +## 响应格式 + +你的每一次回复必须是一个 JSON 对象,包含以下字段: +1. **text**: 一段平和、真诚的回应文字(1-3句话)。注意:text 内容必须是纯文本,禁止使用任何 Markdown 格式。 +2. **visual_metadata**: 描述这次回复应呈现的视觉氛围 + - 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 + +记住:你的存在是帮他成为更好的自己——看清方向,迈出下一步。` + +const companionSystemPromptProfessional = `你是「小石光」,一位尊重专业的交流伙伴。 + +## 角色定位:对等的交流者 + +你面对的用户是一位医疗或心理健康领域的专业人士。他们拥有扎实的专业知识和临床经验,同时也是一个有自己感受和需求的人。 + +## 沟通原则 + +1. **尊重专业**:不解释用户已知的基础知识,在更高层次上交流。涉及他们的专业领域时,可以直接讨论核心问题。 +2. **对等交流**:语气平等坦诚,像同行之间的讨论。不需要过多铺垫和修饰。 +3. **关注个人层面**:专业人士也有自己的疲惫、困惑和情感需求。帮助他们在专业角色之外看见自己作为一个人的需要。 +4. **有深度的对话**:可以进行更深入、更直接的讨论。当他们分享观点时,认真对待并回应,必要时坦诚提出不同视角。 +5. **避免说教**:他们不需要被「教育」。当他们自我怀疑时,不廉价安慰,而是帮他们客观审视处境。 +6. **拒绝讨好**:不因为对方是专业人士就过度恭维。真诚比礼貌更重要。 + +## 记忆上下文 + +### 你记得关于对方的重要信息 +%s + +### 对方和你之间有过以下对话片段 +%s + +在回应时,自然地融入这些记忆。 + +## 响应格式 + +你的每一次回复必须是一个 JSON 对象,包含以下字段: +1. **text**: 一段坦诚、直接的回应文字(1-3句话)。注意:text 内容必须是纯文本,禁止使用任何 Markdown 格式。 +2. **visual_metadata**: 描述这次回复应呈现的视觉氛围 + - 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 + +记住:你的存在是提供一个对等的、可以卸下专业面具的空间。` + +const adminPromptSuffix = "\n\n## 额外信息\n该用户是社区管理员。保持一贯的真诚态度,涉及社区管理话题时可以更直接高效地交流。" + +func getCompanionPrompt(role model.UserRole, isAdmin bool) string { + var prompt string + if model.ProfessionalRoles[role] { + prompt = companionSystemPromptProfessional + } else if role == model.RoleDad { + prompt = companionSystemPromptDad + } else { + prompt = companionSystemPromptMom + } + if isAdmin { + prompt += adminPromptSuffix + } + return prompt +} + +func pronounFor(role model.UserRole) string { + if model.ProfessionalRoles[role] { + return "对方" + } + if role == model.RoleDad { + return "他" + } + return "她" +} const ( maxGuestSessions = 1000 // Maximum number of guest sessions in memory @@ -62,6 +162,7 @@ const ( type ChatService struct { client *openai.Client chatRepo *repository.ChatRepo + userRepo *repository.UserRepo firecrawl *firecrawl.Client // In-memory storage for guest sessions mu sync.RWMutex @@ -69,10 +170,11 @@ type ChatService struct { guestProfiles map[string]map[string]interface{} } -func NewChatService(client *openai.Client, chatRepo *repository.ChatRepo, fc *firecrawl.Client) *ChatService { +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{}), @@ -87,12 +189,21 @@ 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 + role := model.RoleMom + isAdmin := false + if user, err := s.userRepo.FindByID(userID); err == nil { + role = user.Role + isAdmin = user.IsAdmin + } + pronoun := pronounFor(role) + // Load memory from DB profile, turns := s.loadUserMemory(userID) - systemPrompt := fmt.Sprintf(companionSystemPrompt, - formatProfile(profile), - formatTurns(turns), + systemPrompt := fmt.Sprintf(getCompanionPrompt(role, isAdmin), + formatProfile(profile, pronoun), + formatTurns(turns, pronoun), ) webResults := s.searchWebForChat(ctx, msg.Content) @@ -165,9 +276,9 @@ func (s *ChatService) chatGuest(ctx context.Context, msg dto.UserMessage) (*dto. turns := s.guestMemory[sessionID] s.mu.Unlock() - systemPrompt := fmt.Sprintf(companionSystemPrompt, - formatProfile(profile), - formatTurns(turns), + systemPrompt := fmt.Sprintf(getCompanionPrompt(model.RoleMom, false), + formatProfile(profile, "她"), + formatTurns(turns, "她"), ) webResults := s.searchWebForChat(ctx, msg.Content) @@ -280,21 +391,21 @@ func (s *ChatService) saveUserMemory(userID string, profile map[string]interface } } -func formatProfile(profile map[string]interface{}) string { +func formatProfile(profile map[string]interface{}, pronoun string) string { if len(profile) == 0 { return "(暂无记录)" } parts := "" if name, ok := profile["preferred_name"].(string); ok && name != "" { - parts += fmt.Sprintf("- 她喜欢被称为:%s\n", name) + parts += fmt.Sprintf("- %s喜欢被称为:%s\n", pronoun, name) } if hasPets, ok := profile["has_pets"].(bool); ok && hasPets { if details, ok := profile["pet_details"].(string); ok { - parts += fmt.Sprintf("- 她有宠物:%s\n", details) + parts += fmt.Sprintf("- %s有宠物:%s\n", pronoun, details) } } if interests, ok := profile["interests"].([]interface{}); ok && len(interests) > 0 { - parts += "- 她的兴趣:" + parts += fmt.Sprintf("- %s的兴趣:", pronoun) for i, v := range interests { if i > 0 { parts += ", " @@ -304,7 +415,7 @@ func formatProfile(profile map[string]interface{}) string { parts += "\n" } if concerns, ok := profile["concerns"].([]interface{}); ok && len(concerns) > 0 { - parts += "- 她曾表达的担忧:" + parts += fmt.Sprintf("- %s曾表达的担忧:", pronoun) for i, v := range concerns { if i > 0 { parts += ", " @@ -319,7 +430,7 @@ func formatProfile(profile map[string]interface{}) string { return parts } -func formatTurns(turns []map[string]interface{}) string { +func formatTurns(turns []map[string]interface{}, pronoun string) string { if len(turns) == 0 { return "(这是你们的第一次对话)" } @@ -329,7 +440,7 @@ func formatTurns(turns []map[string]interface{}) string { start = len(turns) - 5 } for _, t := range turns[start:] { - result += fmt.Sprintf("她说:%v\n你回复:%v\n", t["user_input"], t["assistant_response"]) + result += fmt.Sprintf("%s说:%v\n你回复:%v\n", pronoun, t["user_input"], t["assistant_response"]) } return result } diff --git a/backend/internal/service/community.go b/backend/internal/service/community.go index 82f66e3f..b856e330 100644 --- a/backend/internal/service/community.go +++ b/backend/internal/service/community.go @@ -655,6 +655,37 @@ func (s *CommunityService) CreateComment(answerID string, req dto.CommentCreate, }, nil } +func (s *CommunityService) UpdateComment(commentID string, req dto.CommentUpdate, user *model.User) (*model.Comment, error) { + comment, err := s.commentRepo.FindByID(commentID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("评论不存在") + } + return nil, err + } + + if comment.AuthorID != user.ID && !user.IsAdmin { + return nil, errors.New("无权修改此评论") + } + + decision := s.moderation.ModerateText(req.Content) + if decision.Result == model.ModerationRejected { + return nil, fmt.Errorf("评论审核未通过: %s", derefStr(decision.Reason)) + } + + comment.Content = req.Content + if decision.Result == model.ModerationNeedManualReview { + comment.Status = model.StatusPendingReview + } + + comment.UpdatedAt = time.Now() + if err := s.commentRepo.Update(comment); err != nil { + return nil, err + } + + return comment, nil +} + func (s *CommunityService) DeleteComment(commentID string, user *model.User) error { comment, err := s.commentRepo.FindByID(commentID) if err != nil { diff --git a/backend/internal/service/community_ai.go b/backend/internal/service/community_ai.go index af8b22e3..5a21a8d2 100644 --- a/backend/internal/service/community_ai.go +++ b/backend/internal/service/community_ai.go @@ -22,6 +22,7 @@ type CommunityAIService struct { questionRepo *repository.QuestionRepo answerRepo *repository.AnswerRepo commentRepo *repository.CommentRepo + userRepo *repository.UserRepo aiUserID string } @@ -31,6 +32,7 @@ func NewCommunityAIService( questionRepo *repository.QuestionRepo, answerRepo *repository.AnswerRepo, commentRepo *repository.CommentRepo, + userRepo *repository.UserRepo, aiUserID string, ) *CommunityAIService { return &CommunityAIService{ @@ -39,6 +41,7 @@ func NewCommunityAIService( questionRepo: questionRepo, answerRepo: answerRepo, commentRepo: commentRepo, + userRepo: userRepo, aiUserID: aiUserID, } } @@ -88,7 +91,8 @@ func (s *CommunityAIService) HandleNewQuestion(questionID string) { threadCtx := fmt.Sprintf("帖子标题:%s\n帖子内容:%s", q.Title, q.Content) searchCtx, sources := s.searchWeb(ctx, q.Title) - reply, err := s.generateReply(ctx, threadCtx, searchCtx, sources) + authorRole, authorIsAdmin := s.lookupUserRole(q.AuthorID) + reply, err := s.generateReply(ctx, threadCtx, searchCtx, sources, authorRole, authorIsAdmin) if err != nil { log.Printf("[CommunityAI] failed to generate reply for question %s: %v", questionID, err) return @@ -135,7 +139,8 @@ func (s *CommunityAIService) HandleNewAnswer(questionID, answerID string) { searchCtx, sources := s.searchWeb(ctx, q.Title) - reply, err := s.generateReply(ctx, sb.String(), searchCtx, sources) + authorRole, authorIsAdmin := s.lookupUserRole(answer.AuthorID) + reply, err := s.generateReply(ctx, sb.String(), searchCtx, sources, authorRole, authorIsAdmin) if err != nil { log.Printf("[CommunityAI] failed to generate reply for answer %s: %v", answerID, err) return @@ -194,15 +199,16 @@ func (s *CommunityAIService) HandleNewComment(answerID, commentID string) { searchCtx, sources := s.searchWeb(ctx, q.Title) - reply, err := s.generateReply(ctx, sb.String(), searchCtx, sources) + triggerComment, err := s.commentRepo.FindByID(commentID) if err != nil { - log.Printf("[CommunityAI] failed to generate comment reply: %v", err) + log.Printf("[CommunityAI] failed to find trigger comment %s: %v", commentID, err) return } - triggerComment, err := s.commentRepo.FindByID(commentID) + authorRole, authorIsAdmin := s.lookupUserRole(triggerComment.AuthorID) + reply, err := s.generateReply(ctx, sb.String(), searchCtx, sources, authorRole, authorIsAdmin) if err != nil { - log.Printf("[CommunityAI] failed to find trigger comment %s: %v", commentID, err) + log.Printf("[CommunityAI] failed to generate comment reply: %v", err) return } @@ -260,16 +266,29 @@ func (s *CommunityAIService) searchWeb(ctx context.Context, query string) (strin return sb.String(), sources } -const communityAISystemPrompt = `你是「小石光」,一位温柔且专业的产后恢复陪伴者,正在社区帖子中回复用户的提问。 +func (s *CommunityAIService) lookupUserRole(userID string) (model.UserRole, bool) { + if s.userRepo == nil { + return model.RoleMom, false + } + user, err := s.userRepo.FindByID(userID) + if err != nil { + return model.RoleMom, false + } + return user.Role, user.IsAdmin +} + +const communityAISystemPromptMom = `你是「小石光」,一位真诚的知心朋友,正在社区帖子中回复用户。 -## 角色定位 -你面对的每一位用户,首先是一个完整的「独立女性」,其次才是新手妈妈。你用温暖、真诚、专业的语气回应她们的问题和困惑。 +## 角色定位:知心朋友 +你面对的用户首先是一个完整的「她自己」。你真正在意的不是给出完美答案,而是让她感到被看见、被理解。 ## 回复原则 -1. **认可与共情优先**:先看见和认可她的感受,再提供建议 -2. **拒绝说教**:用「我了解到...」「有研究表明...」「或许可以试试...」的方式分享 -3. **保护主体性**:提醒她有权利为自己发声,可以寻求帮助 -4. **避免有毒正能量**:承认困难的真实性 +1. **看见与理解优先**:先看见和理解她的内心活动,让她知道这些感受是真实的、合理的 +2. **引导而非安慰**:不用「一切都会好的」「你已经很棒了」这类廉价安慰,而是通过提问帮她梳理内心——「你觉得最在意的是什么?」 +3. **关注自我与当下**:温和地引导她把注意力拉回自己身上——她此刻的感受、她真正的需求 +4. **拒绝说教**:用「我了解到...」「有研究表明...」「或许可以试试...」的方式分享 +5. **有深度的回应**:当她自我否定时,不一味否认,而是通过提问引导她客观审视自己——既看到困难,也看到已经做到的 +6. **避免有毒正能量**:承认困难的真实性 ## 帖子上下文 %s @@ -279,8 +298,8 @@ const communityAISystemPrompt = `你是「小石光」,一位温柔且专业 ## 防幻觉规则(严格遵守) 1. 只基于上述帖子上下文和搜索结果回答 -2. 仅在提供事实性信息时引用来源(如医学知识、研究数据、具体机构推荐),日常共情和鼓励不需要引用 -3. 如果不确定,明确说「关于这一点我不太确定,建议咨询专业医生/心理咨询师」 +2. 仅在提供事实性信息时引用来源(如医学知识、研究数据),日常共情和鼓励不需要引用 +3. 如果不确定,明确说「关于这一点我不太确定,建议咨询专业人士」 4. 绝不编造医疗数据、药物剂量、具体治疗方案 5. 涉及医疗问题时,始终建议咨询专业医生 @@ -291,12 +310,93 @@ const communityAISystemPrompt = `你是「小石光」,一位温柔且专业 - 不要重复用户说过的话 - 移除@提及,直接回复内容` -func (s *CommunityAIService) generateReply(ctx context.Context, threadContext, searchContext string, sources []sourceRef) (string, error) { +const communityAISystemPromptDad = `你是「小石光」,一位耐心的同行者和指导者,正在社区帖子中回复用户。 + +## 角色定位:耐心的指导者 +你面对的用户正在家庭中承担重要角色。你的存在不是为了评判他做得好不好,而是帮他看清眼前的事,一步步做好。 + +## 回复原则 +1. **理解先行**:先理解他面对的具体困境,不急于评价。承认「这确实不容易」比空洞的鼓励更有意义 +2. **引导理解**:帮他理解事物的本质——伴侣的需求、孩子的成长、家庭角色的协作。不是说教,而是帮他「看见」 +3. **给出具体方案**:在理解的基础上,提供可操作的建议。不是笼统的「多关心她」,而是具体的行动 +4. **平和而不讨好**:语气平等、真诚,像一个有经验的朋友在分享 +5. **有深度的回应**:当他感到挫败时,不简单否定感受,而是通过提问帮他理清头绪——「你觉得最大的障碍是什么?」 +6. **拒绝空洞鼓励**:不说「你很厉害」「加油就行」。面对问题时坦诚分析,给出切实的下一步 + +## 帖子上下文 +%s + +## 联网搜索结果 +%s + +## 防幻觉规则(严格遵守) +1. 只基于上述帖子上下文和搜索结果回答 +2. 仅在提供事实性信息时引用来源(如医学知识、研究数据),日常建议不需要引用 +3. 如果不确定,明确说「关于这一点我不太确定,建议咨询专业人士」 +4. 绝不编造医疗数据、药物剂量、具体治疗方案 +5. 涉及医疗问题时,始终建议咨询专业医生 + +## 回复要求 +- 用纯文本回复,不要使用 JSON 格式 +- 语气平和真诚 +- 1-3段话即可,不要过长 +- 不要重复用户说过的话 +- 移除@提及,直接回复内容` + +const communityAISystemPromptProfessional = `你是「小石光」,正在社区帖子中与一位医疗/心理健康领域的专业人士交流。 + +## 角色定位:对等的交流者 +你面对的用户拥有专业知识和临床经验。尊重他们的专业背景,在更高层次上交流。 + +## 回复原则 +1. **尊重专业**:不解释基础知识,直接讨论核心问题 +2. **对等交流**:语气平等坦诚,像同行之间的讨论 +3. **关注个人层面**:即使是专业人士,分享困惑时也需要被认真对待 +4. **有深度的回应**:可以更直接、更深入地讨论,不需要简化 +5. **避免讨好**:不因对方是专业人士就过度恭维,真诚比礼貌更重要 +6. **坦诚交流**:如果信息超出范围,直接说明 + +## 帖子上下文 +%s + +## 联网搜索结果 +%s + +## 防幻觉规则(严格遵守) +1. 只基于上述帖子上下文和搜索结果回答 +2. 仅在提供事实性信息时引用来源 +3. 如果不确定,坦诚说明 +4. 绝不编造医疗数据、药物剂量、具体治疗方案 +5. 涉及具体诊疗方案时,建议结合临床实际判断 + +## 回复要求 +- 用纯文本回复,不要使用 JSON 格式 +- 语气平等直接 +- 1-3段话即可,不要过长 +- 不要重复用户说过的话 +- 移除@提及,直接回复内容` + +func getCommunityAIPrompt(role model.UserRole, isAdmin bool) string { + var prompt string + if model.ProfessionalRoles[role] { + prompt = communityAISystemPromptProfessional + } else if role == model.RoleDad { + prompt = communityAISystemPromptDad + } else { + prompt = communityAISystemPromptMom + } + if isAdmin { + prompt += "\n\n## 额外信息\n该用户是社区管理员。保持真诚态度,涉及社区管理话题时可以更直接高效地交流。" + } + return prompt +} + +func (s *CommunityAIService) generateReply(ctx context.Context, threadContext, searchContext string, sources []sourceRef, role model.UserRole, isAdmin bool) (string, error) { if searchContext == "" { searchContext = "(无搜索结果)" } - systemPrompt := fmt.Sprintf(communityAISystemPrompt, threadContext, searchContext) + systemPrompt := fmt.Sprintf(getCommunityAIPrompt(role, isAdmin), threadContext, searchContext) messages := []openai.Message{ {Role: "system", Content: systemPrompt}, diff --git a/frontend/src/App.vue b/frontend/src/App.vue index b47ed11f..d08cbc1d 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -6,6 +6,7 @@ + @@ -21,6 +22,7 @@ import RoleSelectPanel from '@/components/overlay/RoleSelectPanel.vue' import CarPage from '@/components/overlay/CarPage.vue' 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 WhisperPanel from '@/components/overlay/WhisperPanel.vue' import TaskPanel from '@/components/overlay/TaskPanel.vue' diff --git a/frontend/src/assets/images/background_1.png b/frontend/src/assets/images/background_1.png new file mode 100644 index 00000000..6435de6d --- /dev/null +++ b/frontend/src/assets/images/background_1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:295811c4c818c4ee71c3e085be0b72132c4adf9fd1b87c7ca6b6e82816319185 +size 2990321 diff --git a/frontend/src/assets/images/background_2.png b/frontend/src/assets/images/background_2.png new file mode 100644 index 00000000..d4bbf3ef --- /dev/null +++ b/frontend/src/assets/images/background_2.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:326378a38ec6119c0c0dfbfc67d5c43cc414796d7da0faf9b256ba3f43cdd3ee +size 652450 diff --git a/frontend/src/assets/images/bag.png b/frontend/src/assets/images/bag.png new file mode 100644 index 00000000..9512178a --- /dev/null +++ b/frontend/src/assets/images/bag.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cc08c036e714a39495077764fa0a4e7774c792dbd8050c9af7e5bd158ca82d45 +size 2423652 diff --git a/frontend/src/assets/images/board_down.png b/frontend/src/assets/images/board_down.png new file mode 100644 index 00000000..571a9c0b --- /dev/null +++ b/frontend/src/assets/images/board_down.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9dfae9aa139b039a1ae042362ddabc12c8e39fa0112f3ac90fdc1401c5bbdf73 +size 542389 diff --git a/frontend/src/assets/images/board_med.png b/frontend/src/assets/images/board_med.png new file mode 100644 index 00000000..4d9ca32e --- /dev/null +++ b/frontend/src/assets/images/board_med.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:81c9cf46dd46c3160e71e95d137c4db41e30dccbfad4482d25a47da52db8457c +size 3971325 diff --git a/frontend/src/assets/images/board_up.png b/frontend/src/assets/images/board_up.png new file mode 100644 index 00000000..a31b7c7e --- /dev/null +++ b/frontend/src/assets/images/board_up.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:998c1e1c4bd9da064a29c53f3c06c6fcb1577cd9d119ae2e6456ff33962f6b67 +size 703839 diff --git a/frontend/src/assets/images/note.png b/frontend/src/assets/images/note.png new file mode 100644 index 00000000..f9d0fef4 --- /dev/null +++ b/frontend/src/assets/images/note.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a5bc30e1453a6ad0fabcdefd2c269684be3c35d89bc16a34d4deb65026806261 +size 1212385 diff --git a/frontend/src/assets/images/note_1.png b/frontend/src/assets/images/note_1.png new file mode 100644 index 00000000..7b4cb834 --- /dev/null +++ b/frontend/src/assets/images/note_1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bc318717f5b86e26bc017d4dc0a98c69e6cbdbe911b17555349f1ad5836f694a +size 2422795 diff --git a/frontend/src/assets/images/note_2.png b/frontend/src/assets/images/note_2.png new file mode 100644 index 00000000..94b783c2 --- /dev/null +++ b/frontend/src/assets/images/note_2.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:351f76a8560459b425817d5b513604cbfc4e7386e0120a7d4296419d8fee40fa +size 1204957 diff --git a/frontend/src/assets/images/note_3.png b/frontend/src/assets/images/note_3.png new file mode 100644 index 00000000..293a98bb --- /dev/null +++ b/frontend/src/assets/images/note_3.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8fde17eb9d97cbab720b017f8af539a933c183f744ecba037eac58a0b7fb05b2 +size 2498427 diff --git a/frontend/src/assets/images/note_pro.png b/frontend/src/assets/images/note_pro.png new file mode 100755 index 00000000..fc36ce2d --- /dev/null +++ b/frontend/src/assets/images/note_pro.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c4aff588be785aea224771e1bbe02b13780e7ae94a3e68799efe4a3b3d281719 +size 952410 diff --git a/frontend/src/assets/images/paper.png b/frontend/src/assets/images/paper.png new file mode 100644 index 00000000..b0453c37 --- /dev/null +++ b/frontend/src/assets/images/paper.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fbf45219ed4943826bdb6ddf7e8d3d279e96eb7094f419494976adfdb72de3c1 +size 3643110 diff --git a/frontend/src/components/overlay/BarPage.vue b/frontend/src/components/overlay/BarPage.vue new file mode 100644 index 00000000..e31cde01 --- /dev/null +++ b/frontend/src/components/overlay/BarPage.vue @@ -0,0 +1,1677 @@ +