From 2b6d115cc6278369cc7f2f7886e8780c1354dde8 Mon Sep 17 00:00:00 2001 From: Koishi Date: Sat, 7 Mar 2026 15:40:00 +0800 Subject: [PATCH 01/23] chore: remove frontend.new --- frontend.new/.omc/state/last-tool-error.json | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 frontend.new/.omc/state/last-tool-error.json diff --git a/frontend.new/.omc/state/last-tool-error.json b/frontend.new/.omc/state/last-tool-error.json deleted file mode 100644 index 50c8bb76..00000000 --- a/frontend.new/.omc/state/last-tool-error.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "tool_name": "Bash", - "tool_input_preview": "{\"command\":\"cd /home/koishi/MomShell && git add frontend.new/src/components/scene/BeachScene.vue frontend.new/src/constants/layers.ts frontend.new/vite.config.ts && git rebase --continue\",\"description...", - "error": "Exit code 1\n[detached HEAD cc7ec54] refactor: restructure beach scene into Vue 3 + Vite + TypeScript\n 5 files changed, 2174 insertions(+), 1221 deletions(-)\n rename frontend.new/assets/{render-sand.js => render-sand.ts} (87%)\nRebasing (16/58)\rAuto-merging frontend.new/src/components/scene/BeachScene.vue\nCONFLICT (content): Merge conflict in frontend.new/src/components/scene/BeachScene.vue\nAuto-merging frontend.new/src/components/scene/SpritesLayer.vue\nCONFLICT (add/add): Merge conflict in fronte...", - "timestamp": "2026-03-07T06:54:53.196Z", - "retry_count": 1 -} \ No newline at end of file From 7f77ad56b792c247d505739bfbb3c123417265d6 Mon Sep 17 00:00:00 2001 From: Koishi Date: Sat, 7 Mar 2026 20:02:30 +0800 Subject: [PATCH 02/23] feat: add community AI replies and web search via Firecrawl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Firecrawl web search client (pkg/firecrawl) - Add CommunityAIService that generates AI answers/comments when users mention @小石光 or @koishi in posts or replies - AI reads full thread context (question + answers + comments) and searches the web for grounded responses with anti-hallucination prompts - Add web search to chat service (both authenticated and guest) - Auto-create AI user (xiaoshiguang) on startup with ai_assistant role - Graceful degradation when FIRECRAWL_API_KEY or OPENAI_API_KEY not set Co-Authored-By: Claude Opus 4.6 --- .env.example | 3 + backend/cmd/server/main.go | 54 ++++- backend/internal/config/config.go | 4 + backend/internal/handler/comment.go | 10 +- backend/internal/handler/question.go | 13 +- backend/internal/service/chat.go | 46 ++++- backend/internal/service/community_ai.go | 239 +++++++++++++++++++++++ backend/pkg/firecrawl/client.go | 79 ++++++++ 8 files changed, 438 insertions(+), 10 deletions(-) create mode 100644 backend/internal/service/community_ai.go create mode 100644 backend/pkg/firecrawl/client.go diff --git a/.env.example b/.env.example index 7510d285..799ca087 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,9 @@ OPENAI_API_KEY= OPENAI_BASE_URL=https://api-inference.modelscope.cn/v1 OPENAI_MODEL=Qwen/Qwen3-235B-A22B +# ==================== Firecrawl (Web Search) ==================== +FIRECRAWL_API_KEY= + # ==================== Server ==================== PORT=8000 diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index c466e217..73905406 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -13,6 +13,7 @@ import ( "github.com/momshell/backend/internal/repository" "github.com/momshell/backend/internal/router" "github.com/momshell/backend/internal/service" + "github.com/momshell/backend/pkg/firecrawl" "github.com/momshell/backend/pkg/openai" "github.com/momshell/backend/pkg/password" ) @@ -63,9 +64,26 @@ func main() { log.Println("[WARN] OPENAI_API_KEY not set, chat service will not work") chatClient = openai.NewClient("dummy", cfg.OpenAIBaseURL, cfg.OpenAIModel) } - chatService := service.NewChatService(chatClient, chatRepo) + + var firecrawlClient *firecrawl.Client + if cfg.FirecrawlAPIKey != "" { + firecrawlClient = firecrawl.NewClient(cfg.FirecrawlAPIKey) + } + + chatService := service.NewChatService(chatClient, chatRepo, firecrawlClient) echoService := service.NewEchoService(chatClient, echoRepo, userRepo) + // Ensure AI user exists for community AI replies + aiUserID := ensureAIUser(userRepo) + var communityAIService *service.CommunityAIService + if aiUserID != "" && cfg.OpenAIAPIKey != "" { + communityAIService = service.NewCommunityAIService( + chatClient, firecrawlClient, + questionRepo, answerRepo, commentRepo, + aiUserID, + ) + } + userService := service.NewUserService( db, userRepo, questionRepo, answerRepo, interactionRepo, communityService, @@ -77,9 +95,9 @@ func main() { // Initialize handlers authHandler := handler.NewAuthHandler(authService) - questionHandler := handler.NewQuestionHandler(communityService, authService) + questionHandler := handler.NewQuestionHandler(communityService, authService, communityAIService) answerHandler := handler.NewAnswerHandler(communityService, authService) - commentHandler := handler.NewCommentHandler(communityService, authService) + commentHandler := handler.NewCommentHandler(communityService, authService, communityAIService) interactionHandler := handler.NewInteractionHandler(communityService) tagHandler := handler.NewTagHandler(communityService) chatHandler := handler.NewChatHandler(chatService) @@ -143,3 +161,33 @@ func createInitialAdmin(cfg *config.Config, userRepo *repository.UserRepo) { log.Printf("Admin user created: %s", cfg.AdminUsername) } + +func ensureAIUser(userRepo *repository.UserRepo) string { + user, err := userRepo.FindByUsernameOrEmail("xiaoshiguang") + if err == nil { + return user.ID + } + + hash, err := password.Hash("ai-user-no-login") + if err != nil { + log.Printf("Failed to hash AI user password: %v", err) + return "" + } + + aiUser := &model.User{ + Username: "xiaoshiguang", + Email: "ai@momshell.com", + PasswordHash: hash, + Nickname: "小石光", + Role: model.RoleAIAssistant, + IsActive: true, + } + + if err := userRepo.Create(aiUser); err != nil { + log.Printf("Failed to create AI user: %v", err) + return "" + } + + log.Printf("AI user created: %s (ID: %s)", aiUser.Username, aiUser.ID) + return aiUser.ID +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index fb6a0b66..3d2c4713 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -23,6 +23,9 @@ type Config struct { OpenAIBaseURL string OpenAIModel string + // Firecrawl (web search) + FirecrawlAPIKey string + // Server Port string @@ -46,6 +49,7 @@ func Load() *Config { OpenAIAPIKey: getEnv("OPENAI_API_KEY", ""), OpenAIBaseURL: getEnv("OPENAI_BASE_URL", "https://api-inference.modelscope.cn/v1"), OpenAIModel: getEnv("OPENAI_MODEL", "Qwen/Qwen2.5-72B-Instruct"), + FirecrawlAPIKey: getEnv("FIRECRAWL_API_KEY", ""), Port: getEnv("PORT", "8000"), AdminUsername: getEnv("ADMIN_USERNAME", ""), AdminEmail: getEnv("ADMIN_EMAIL", ""), diff --git a/backend/internal/handler/comment.go b/backend/internal/handler/comment.go index 02dec0cd..24f449f1 100644 --- a/backend/internal/handler/comment.go +++ b/backend/internal/handler/comment.go @@ -12,10 +12,11 @@ import ( type CommentHandler struct { communityService *service.CommunityService authService *service.AuthService + communityAI *service.CommunityAIService } -func NewCommentHandler(communityService *service.CommunityService, authService *service.AuthService) *CommentHandler { - return &CommentHandler{communityService: communityService, authService: authService} +func NewCommentHandler(communityService *service.CommunityService, authService *service.AuthService, communityAI *service.CommunityAIService) *CommentHandler { + return &CommentHandler{communityService: communityService, authService: authService, communityAI: communityAI} } // GET /api/v1/community/answers/:id/comments @@ -59,6 +60,11 @@ func (h *CommentHandler) Create(c *gin.Context) { return } + // Trigger AI reply if content mentions @小石光/@koishi + if h.communityAI != nil && service.ContainsMention(req.Content) { + go h.communityAI.HandleNewComment(answerID, comment.ID) + } + c.JSON(http.StatusCreated, comment) } diff --git a/backend/internal/handler/question.go b/backend/internal/handler/question.go index ad396881..1f8be00b 100644 --- a/backend/internal/handler/question.go +++ b/backend/internal/handler/question.go @@ -6,16 +6,18 @@ import ( "github.com/gin-gonic/gin" "github.com/momshell/backend/internal/dto" "github.com/momshell/backend/internal/middleware" + "github.com/momshell/backend/internal/model" "github.com/momshell/backend/internal/service" ) type QuestionHandler struct { communityService *service.CommunityService authService *service.AuthService + communityAI *service.CommunityAIService } -func NewQuestionHandler(communityService *service.CommunityService, authService *service.AuthService) *QuestionHandler { - return &QuestionHandler{communityService: communityService, authService: authService} +func NewQuestionHandler(communityService *service.CommunityService, authService *service.AuthService, communityAI *service.CommunityAIService) *QuestionHandler { + return &QuestionHandler{communityService: communityService, authService: authService, communityAI: communityAI} } // GET /api/v1/community/questions @@ -122,6 +124,13 @@ func (h *QuestionHandler) Create(c *gin.Context) { return } + // Trigger AI reply if content mentions @小石光/@koishi + if h.communityAI != nil && question.Status == model.StatusPublished { + if service.ContainsMention(req.Title) || service.ContainsMention(req.Content) { + go h.communityAI.HandleNewQuestion(question.ID) + } + } + c.JSON(http.StatusCreated, gin.H{"id": question.ID, "status": string(question.Status)}) } diff --git a/backend/internal/service/chat.go b/backend/internal/service/chat.go index 58402dac..317eb93a 100644 --- a/backend/internal/service/chat.go +++ b/backend/internal/service/chat.go @@ -6,12 +6,14 @@ import ( "fmt" "log" "regexp" + "strings" "sync" "github.com/google/uuid" "github.com/momshell/backend/internal/dto" "github.com/momshell/backend/internal/model" "github.com/momshell/backend/internal/repository" + "github.com/momshell/backend/pkg/firecrawl" "github.com/momshell/backend/pkg/openai" ) @@ -54,18 +56,20 @@ const companionSystemPrompt = `你是「贝壳姐姐」,一位「曾走过这 记住:你的存在不是为了「解决她的问题」,而是让她感到——在这一刻,她并不孤单。` type ChatService struct { - client *openai.Client - chatRepo *repository.ChatRepo + client *openai.Client + chatRepo *repository.ChatRepo + firecrawl *firecrawl.Client // In-memory storage for guest sessions mu sync.RWMutex guestMemory map[string][]map[string]interface{} guestProfiles map[string]map[string]interface{} } -func NewChatService(client *openai.Client, chatRepo *repository.ChatRepo) *ChatService { +func NewChatService(client *openai.Client, chatRepo *repository.ChatRepo, fc *firecrawl.Client) *ChatService { return &ChatService{ client: client, chatRepo: chatRepo, + firecrawl: fc, guestMemory: make(map[string][]map[string]interface{}), guestProfiles: make(map[string]map[string]interface{}), } @@ -87,6 +91,11 @@ func (s *ChatService) chatAuthenticated(ctx context.Context, msg dto.UserMessage formatTurns(turns), ) + webResults := s.searchWebForChat(ctx, msg.Content) + if webResults != "" { + systemPrompt += "\n\n## 联网搜索参考\n" + webResults + "\n如有引用搜索内容,请自然融入回答,标注来源。不确定的信息请标明。" + } + messages := []openai.Message{ {Role: "system", Content: systemPrompt}, {Role: "user", Content: msg.Content}, @@ -140,6 +149,11 @@ func (s *ChatService) chatGuest(ctx context.Context, msg dto.UserMessage) (*dto. formatTurns(turns), ) + webResults := s.searchWebForChat(ctx, msg.Content) + if webResults != "" { + systemPrompt += "\n\n## 联网搜索参考\n" + webResults + "\n如有引用搜索内容,请自然融入回答,标注来源。不确定的信息请标明。" + } + messages := []openai.Message{ {Role: "system", Content: systemPrompt}, {Role: "user", Content: msg.Content}, @@ -169,6 +183,32 @@ func (s *ChatService) chatGuest(ctx context.Context, msg dto.UserMessage) (*dto. return buildVisualResponse(parsed, memoryUpdated), nil } +func (s *ChatService) searchWebForChat(ctx context.Context, userMessage string) string { + if s.firecrawl == nil { + return "" + } + results, err := s.firecrawl.Search(ctx, userMessage, 3) + if err != nil { + log.Printf("[ChatService] web search failed: %v", err) + return "" + } + if len(results) == 0 { + return "" + } + var sb strings.Builder + for i, r := range results { + content := r.Markdown + if content == "" { + content = r.Description + } + if len([]rune(content)) > 300 { + content = string([]rune(content)[:300]) + "..." + } + fmt.Fprintf(&sb, "[来源%d] %s (%s): %s\n", i+1, r.Title, r.URL, content) + } + return sb.String() +} + func (s *ChatService) GetProfile(userID string) (*dto.ChatProfile, error) { mem, err := s.chatRepo.FindByUserID(userID) if err != nil { diff --git a/backend/internal/service/community_ai.go b/backend/internal/service/community_ai.go new file mode 100644 index 00000000..39357b55 --- /dev/null +++ b/backend/internal/service/community_ai.go @@ -0,0 +1,239 @@ +package service + +import ( + "context" + "fmt" + "log" + "regexp" + "strings" + "time" + + "github.com/momshell/backend/internal/model" + "github.com/momshell/backend/internal/repository" + "github.com/momshell/backend/pkg/firecrawl" + "github.com/momshell/backend/pkg/openai" +) + +var mentionPattern = regexp.MustCompile(`@(小石光|koishi)`) + +type CommunityAIService struct { + client *openai.Client + firecrawl *firecrawl.Client + questionRepo *repository.QuestionRepo + answerRepo *repository.AnswerRepo + commentRepo *repository.CommentRepo + aiUserID string +} + +func NewCommunityAIService( + client *openai.Client, + fc *firecrawl.Client, + questionRepo *repository.QuestionRepo, + answerRepo *repository.AnswerRepo, + commentRepo *repository.CommentRepo, + aiUserID string, +) *CommunityAIService { + return &CommunityAIService{ + client: client, + firecrawl: fc, + questionRepo: questionRepo, + answerRepo: answerRepo, + commentRepo: commentRepo, + aiUserID: aiUserID, + } +} + +// ContainsMention checks if content mentions @小石光 or @koishi. +func ContainsMention(content string) bool { + return mentionPattern.MatchString(content) +} + +// HandleNewQuestion generates an AI answer for a question that mentions @小石光/@koishi. +func (s *CommunityAIService) HandleNewQuestion(questionID string) { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + q, err := s.questionRepo.FindByID(questionID) + if err != nil { + log.Printf("[CommunityAI] failed to find question %s: %v", questionID, err) + return + } + + threadCtx := fmt.Sprintf("帖子标题:%s\n帖子内容:%s", q.Title, q.Content) + searchCtx := s.searchWeb(ctx, q.Title) + + reply, err := s.generateReply(ctx, threadCtx, searchCtx) + if err != nil { + log.Printf("[CommunityAI] failed to generate reply for question %s: %v", questionID, err) + return + } + + answer := &model.Answer{ + QuestionID: questionID, + AuthorID: s.aiUserID, + Content: reply, + AuthorRole: model.RoleAIAssistant, + IsProfessional: false, + Status: model.StatusPublished, + } + + if err := s.answerRepo.Create(answer); err != nil { + log.Printf("[CommunityAI] failed to create answer for question %s: %v", questionID, err) + return + } + + _ = s.questionRepo.IncrementAnswerCount(questionID) + log.Printf("[CommunityAI] AI answered question %s", questionID) +} + +// HandleNewComment generates an AI comment reply when a comment mentions @小石光/@koishi. +func (s *CommunityAIService) HandleNewComment(answerID, commentID string) { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + answer, err := s.answerRepo.FindByID(answerID) + if err != nil { + log.Printf("[CommunityAI] failed to find answer %s: %v", answerID, err) + return + } + + q, err := s.questionRepo.FindByID(answer.QuestionID) + if err != nil { + log.Printf("[CommunityAI] failed to find question %s: %v", answer.QuestionID, err) + return + } + + comments, err := s.commentRepo.FindByAnswerID(answerID) + if err != nil { + log.Printf("[CommunityAI] failed to find comments for answer %s: %v", answerID, err) + return + } + + var sb strings.Builder + fmt.Fprintf(&sb, "帖子标题:%s\n帖子内容:%s\n\n", q.Title, q.Content) + fmt.Fprintf(&sb, "当前回答:%s\n\n评论区对话:\n", answer.Content) + for _, c := range comments { + role := "用户" + if c.AuthorID == s.aiUserID { + role = "贝壳姐姐" + } + fmt.Fprintf(&sb, "%s:%s\n", role, c.Content) + } + + searchCtx := s.searchWeb(ctx, q.Title) + + reply, err := s.generateReply(ctx, sb.String(), searchCtx) + if err != nil { + log.Printf("[CommunityAI] failed to generate comment reply: %v", err) + return + } + + triggerComment, err := s.commentRepo.FindByID(commentID) + if err != nil { + log.Printf("[CommunityAI] failed to find trigger comment %s: %v", commentID, err) + return + } + + comment := &model.Comment{ + AnswerID: answerID, + AuthorID: s.aiUserID, + ParentID: &commentID, + ReplyToUserID: &triggerComment.AuthorID, + Content: reply, + Status: model.StatusPublished, + } + + if err := s.commentRepo.Create(comment); err != nil { + log.Printf("[CommunityAI] failed to create comment reply: %v", err) + return + } + + _ = s.answerRepo.IncrementCommentCount(answerID) + log.Printf("[CommunityAI] AI replied to comment %s on answer %s", commentID, answerID) +} + +func (s *CommunityAIService) searchWeb(ctx context.Context, query string) string { + if s.firecrawl == nil { + return "" + } + + results, err := s.firecrawl.Search(ctx, query, 3) + if err != nil { + log.Printf("[CommunityAI] web search failed: %v", err) + return "" + } + if len(results) == 0 { + return "" + } + + var sb strings.Builder + for i, r := range results { + content := r.Markdown + if content == "" { + content = r.Description + } + if len([]rune(content)) > 500 { + content = string([]rune(content)[:500]) + "..." + } + fmt.Fprintf(&sb, "[来源%d] %s\n链接:%s\n内容:%s\n\n", i+1, r.Title, r.URL, content) + } + return sb.String() +} + +const communityAISystemPrompt = `你是「贝壳姐姐」,一位温柔且专业的产后恢复陪伴者,正在社区帖子中回复用户的提问。 + +## 角色定位 +你面对的每一位用户,首先是一个完整的「独立女性」,其次才是新手妈妈。你用温暖、真诚、专业的语气回应她们的问题和困惑。 + +## 回复原则 +1. **认可与共情优先**:先看见和认可她的感受,再提供建议 +2. **拒绝说教**:用「我了解到...」「有研究表明...」「或许可以试试...」的方式分享 +3. **保护主体性**:提醒她有权利为自己发声,可以寻求帮助 +4. **避免有毒正能量**:承认困难的真实性 + +## 帖子上下文 +%s + +## 联网搜索结果 +%s + +## 防幻觉规则(严格遵守) +1. 只基于上述帖子上下文和搜索结果回答 +2. 当搜索结果包含相关信息时,引用来源(如「根据[来源1]...」) +3. 如果不确定,明确说「关于这一点我不太确定,建议咨询专业医生/心理咨询师」 +4. 绝不编造医疗数据、药物剂量、具体治疗方案 +5. 涉及医疗问题时,始终建议咨询专业医生 + +## 回复要求 +- 用纯文本回复,不要使用 JSON 格式 +- 语气温暖自然,像朋友聊天 +- 1-3段话即可,不要过长 +- 不要重复用户说过的话 +- 移除@提及,直接回复内容` + +func (s *CommunityAIService) generateReply(ctx context.Context, threadContext, searchContext string) (string, error) { + if searchContext == "" { + searchContext = "(无搜索结果)" + } + + systemPrompt := fmt.Sprintf(communityAISystemPrompt, threadContext, searchContext) + + messages := []openai.Message{ + {Role: "system", Content: systemPrompt}, + {Role: "user", Content: "请根据帖子上下文,给出一个温暖、有帮助的回复。"}, + } + + reply, err := s.client.Chat(ctx, messages) + if err != nil { + return "", fmt.Errorf("AI 服务调用失败: %w", err) + } + + reply = mentionPattern.ReplaceAllString(reply, "") + reply = strings.TrimSpace(reply) + + if reply == "" { + reply = "谢谢你的分享。如果需要更多帮助,随时可以找我聊聊。" + } + + return reply, nil +} diff --git a/backend/pkg/firecrawl/client.go b/backend/pkg/firecrawl/client.go new file mode 100644 index 00000000..defe97ae --- /dev/null +++ b/backend/pkg/firecrawl/client.go @@ -0,0 +1,79 @@ +package firecrawl + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" +) + +type Client struct { + apiKey string + http *http.Client +} + +func NewClient(apiKey string) *Client { + return &Client{ + apiKey: apiKey, + http: &http.Client{}, + } +} + +type SearchResult struct { + URL string `json:"url"` + Title string `json:"title"` + Description string `json:"description"` + Markdown string `json:"markdown"` +} + +type searchRequest struct { + Query string `json:"query"` + Limit int `json:"limit"` +} + +type searchResponse struct { + Success bool `json:"success"` + Data []SearchResult `json:"data"` +} + +func (c *Client) Search(ctx context.Context, query string, limit int) ([]SearchResult, error) { + if c.apiKey == "" { + return nil, nil + } + + body, err := json.Marshal(searchRequest{Query: query, Limit: limit}) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", "https://api.firecrawl.dev/v1/search", bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.apiKey) + + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("firecrawl search failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("firecrawl search failed: status %d, body: %s", resp.StatusCode, string(respBody)) + } + + var searchResp searchResponse + if err := json.Unmarshal(respBody, &searchResp); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return searchResp.Data, nil +} From 22f2b20d658d4397ac1dfb4fd1cf27aae4e56910 Mon Sep 17 00:00:00 2001 From: Koishi Date: Sat, 7 Mar 2026 20:31:32 +0800 Subject: [PATCH 03/23] feat: auto AI reply on all new posts and comment replies, with source footnotes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AI replies to every new published question by default (no @mention needed) - AI replies to comments on AI answers, replies to AI comments, or @mentions - Append source footnotes (title + URL) when AI cites [来源N] in replies Co-Authored-By: Claude Opus 4.6 --- backend/internal/handler/comment.go | 4 +- backend/internal/handler/question.go | 6 +- backend/internal/service/community_ai.go | 83 ++++++++++++++++++++---- 3 files changed, 76 insertions(+), 17 deletions(-) diff --git a/backend/internal/handler/comment.go b/backend/internal/handler/comment.go index 24f449f1..3d62b793 100644 --- a/backend/internal/handler/comment.go +++ b/backend/internal/handler/comment.go @@ -60,8 +60,8 @@ func (h *CommentHandler) Create(c *gin.Context) { return } - // Trigger AI reply if content mentions @小石光/@koishi - if h.communityAI != nil && service.ContainsMention(req.Content) { + // Trigger AI reply if commenting on AI's answer, replying to AI, or @mentioning AI + if h.communityAI != nil && h.communityAI.ShouldReplyToComment(req.Content, answerID, req.ParentID) { go h.communityAI.HandleNewComment(answerID, comment.ID) } diff --git a/backend/internal/handler/question.go b/backend/internal/handler/question.go index 1f8be00b..bb9a8e59 100644 --- a/backend/internal/handler/question.go +++ b/backend/internal/handler/question.go @@ -124,11 +124,9 @@ func (h *QuestionHandler) Create(c *gin.Context) { return } - // Trigger AI reply if content mentions @小石光/@koishi + // Trigger AI reply for every new published question if h.communityAI != nil && question.Status == model.StatusPublished { - if service.ContainsMention(req.Title) || service.ContainsMention(req.Content) { - go h.communityAI.HandleNewQuestion(question.ID) - } + go h.communityAI.HandleNewQuestion(question.ID) } c.JSON(http.StatusCreated, gin.H{"id": question.ID, "status": string(question.Status)}) diff --git a/backend/internal/service/community_ai.go b/backend/internal/service/community_ai.go index 39357b55..c1fa698c 100644 --- a/backend/internal/service/community_ai.go +++ b/backend/internal/service/community_ai.go @@ -48,6 +48,27 @@ func ContainsMention(content string) bool { return mentionPattern.MatchString(content) } +// ShouldReplyToComment returns true if AI should reply to this comment: +// - comment mentions @小石光/@koishi, OR +// - comment is on an AI-authored answer, OR +// - comment is a reply to an AI-authored comment. +func (s *CommunityAIService) ShouldReplyToComment(content, answerID string, parentID *string) bool { + if ContainsMention(content) { + return true + } + answer, err := s.answerRepo.FindByID(answerID) + if err == nil && answer.AuthorID == s.aiUserID { + return true + } + if parentID != nil { + parent, err := s.commentRepo.FindByID(*parentID) + if err == nil && parent.AuthorID == s.aiUserID { + return true + } + } + return false +} + // HandleNewQuestion generates an AI answer for a question that mentions @小石光/@koishi. func (s *CommunityAIService) HandleNewQuestion(questionID string) { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) @@ -60,9 +81,9 @@ func (s *CommunityAIService) HandleNewQuestion(questionID string) { } threadCtx := fmt.Sprintf("帖子标题:%s\n帖子内容:%s", q.Title, q.Content) - searchCtx := s.searchWeb(ctx, q.Title) + searchCtx, sources := s.searchWeb(ctx, q.Title) - reply, err := s.generateReply(ctx, threadCtx, searchCtx) + reply, err := s.generateReply(ctx, threadCtx, searchCtx, sources) if err != nil { log.Printf("[CommunityAI] failed to generate reply for question %s: %v", questionID, err) return @@ -120,9 +141,9 @@ func (s *CommunityAIService) HandleNewComment(answerID, commentID string) { fmt.Fprintf(&sb, "%s:%s\n", role, c.Content) } - searchCtx := s.searchWeb(ctx, q.Title) + searchCtx, sources := s.searchWeb(ctx, q.Title) - reply, err := s.generateReply(ctx, sb.String(), searchCtx) + reply, err := s.generateReply(ctx, sb.String(), searchCtx, sources) if err != nil { log.Printf("[CommunityAI] failed to generate comment reply: %v", err) return @@ -152,20 +173,27 @@ func (s *CommunityAIService) HandleNewComment(answerID, commentID string) { log.Printf("[CommunityAI] AI replied to comment %s on answer %s", commentID, answerID) } -func (s *CommunityAIService) searchWeb(ctx context.Context, query string) string { +type sourceRef struct { + index int + title string + url string +} + +func (s *CommunityAIService) searchWeb(ctx context.Context, query string) (string, []sourceRef) { if s.firecrawl == nil { - return "" + return "", nil } results, err := s.firecrawl.Search(ctx, query, 3) if err != nil { log.Printf("[CommunityAI] web search failed: %v", err) - return "" + return "", nil } if len(results) == 0 { - return "" + return "", nil } + var sources []sourceRef var sb strings.Builder for i, r := range results { content := r.Markdown @@ -176,11 +204,12 @@ func (s *CommunityAIService) searchWeb(ctx context.Context, query string) string content = string([]rune(content)[:500]) + "..." } fmt.Fprintf(&sb, "[来源%d] %s\n链接:%s\n内容:%s\n\n", i+1, r.Title, r.URL, content) + sources = append(sources, sourceRef{index: i + 1, title: r.Title, url: r.URL}) } - return sb.String() + return sb.String(), sources } -const communityAISystemPrompt = `你是「贝壳姐姐」,一位温柔且专业的产后恢复陪伴者,正在社区帖子中回复用户的提问。 +const communityAISystemPrompt = `你是「小石光」,一位温柔且专业的产后恢复陪伴者,正在社区帖子中回复用户的提问。 ## 角色定位 你面对的每一位用户,首先是一个完整的「独立女性」,其次才是新手妈妈。你用温暖、真诚、专业的语气回应她们的问题和困惑。 @@ -211,7 +240,7 @@ const communityAISystemPrompt = `你是「贝壳姐姐」,一位温柔且专 - 不要重复用户说过的话 - 移除@提及,直接回复内容` -func (s *CommunityAIService) generateReply(ctx context.Context, threadContext, searchContext string) (string, error) { +func (s *CommunityAIService) generateReply(ctx context.Context, threadContext, searchContext string, sources []sourceRef) (string, error) { if searchContext == "" { searchContext = "(无搜索结果)" } @@ -235,5 +264,37 @@ func (s *CommunityAIService) generateReply(ctx context.Context, threadContext, s reply = "谢谢你的分享。如果需要更多帮助,随时可以找我聊聊。" } + // Append footnotes for any cited sources + reply = appendSourceFootnotes(reply, sources) + return reply, nil } + +var sourceRefPattern = regexp.MustCompile(`\[来源(\d+)\]`) + +func appendSourceFootnotes(reply string, sources []sourceRef) string { + matches := sourceRefPattern.FindAllStringSubmatch(reply, -1) + if len(matches) == 0 || len(sources) == 0 { + return reply + } + + cited := make(map[int]bool) + for _, m := range matches { + var idx int + if _, err := fmt.Sscanf(m[1], "%d", &idx); err == nil { + cited[idx] = true + } + } + + var footnotes strings.Builder + for _, src := range sources { + if cited[src.index] { + fmt.Fprintf(&footnotes, "\n[来源%d] %s %s", src.index, src.title, src.url) + } + } + + if footnotes.Len() > 0 { + reply += "\n" + footnotes.String() + } + return reply +} From 536d860fe7e1e2bedd18a470fbfebeafdd7373ee Mon Sep 17 00:00:00 2001 From: Koishi Date: Sat, 7 Mar 2026 20:35:15 +0800 Subject: [PATCH 04/23] fix: renumber cited sources sequentially in AI replies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When AI only cites [来源3], it now gets renumbered to [来源1] in both the reply text and the footnote, so citations always start from 1. Co-Authored-By: Claude Opus 4.6 --- backend/internal/service/community_ai.go | 44 +++++++++++++++++++----- 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/backend/internal/service/community_ai.go b/backend/internal/service/community_ai.go index c1fa698c..1dc7213b 100644 --- a/backend/internal/service/community_ai.go +++ b/backend/internal/service/community_ai.go @@ -278,23 +278,51 @@ func appendSourceFootnotes(reply string, sources []sourceRef) string { return reply } - cited := make(map[int]bool) + // 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 { - cited[idx] = true + if _, err := fmt.Sscanf(m[1], "%d", &idx); err == nil && !seen[idx] { + seen[idx] = true + citedOrder = append(citedOrder, idx) } } - var footnotes strings.Builder + // Build renumber map: old index -> new sequential index + renumber := make(map[int]int) + for newIdx, oldIdx := range citedOrder { + renumber[oldIdx] = newIdx + 1 + } + + // Replace [来源N] with renumbered [来源M] in reply text + replaced := sourceRefPattern.ReplaceAllStringFunc(reply, func(match string) string { + sub := sourceRefPattern.FindStringSubmatch(match) + var oldIdx int + if _, err := fmt.Sscanf(sub[1], "%d", &oldIdx); err == nil { + if newIdx, ok := renumber[oldIdx]; ok { + return fmt.Sprintf("[来源%d]", newIdx) + } + } + return match + }) + + // Build source index map for quick lookup + sourceMap := make(map[int]sourceRef) for _, src := range sources { - if cited[src.index] { - fmt.Fprintf(&footnotes, "\n[来源%d] %s %s", src.index, src.title, src.url) + sourceMap[src.index] = src + } + + // Append footnotes with renumbered indices + var footnotes strings.Builder + for _, oldIdx := range citedOrder { + if src, ok := sourceMap[oldIdx]; ok { + fmt.Fprintf(&footnotes, "\n[来源%d] %s %s", renumber[oldIdx], src.title, src.url) } } if footnotes.Len() > 0 { - reply += "\n" + footnotes.String() + replaced += "\n" + footnotes.String() } - return reply + return replaced } From 9d46e229d89dfd4ab885208fd2a0a09aa9619c87 Mon Sep 17 00:00:00 2001 From: Koishi Date: Sat, 7 Mar 2026 20:43:31 +0800 Subject: [PATCH 05/23] fix: only cite sources for factual claims, not emotional support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update AI system prompt to only reference [来源N] when providing factual information like medical knowledge or specific recommendations, not for everyday empathy and encouragement. Co-Authored-By: Claude Opus 4.6 --- backend/internal/service/community_ai.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/internal/service/community_ai.go b/backend/internal/service/community_ai.go index 1dc7213b..393b180e 100644 --- a/backend/internal/service/community_ai.go +++ b/backend/internal/service/community_ai.go @@ -228,7 +228,7 @@ const communityAISystemPrompt = `你是「小石光」,一位温柔且专业 ## 防幻觉规则(严格遵守) 1. 只基于上述帖子上下文和搜索结果回答 -2. 当搜索结果包含相关信息时,引用来源(如「根据[来源1]...」) +2. 仅在提供事实性信息时引用来源(如医学知识、研究数据、具体机构推荐),日常共情和鼓励不需要引用 3. 如果不确定,明确说「关于这一点我不太确定,建议咨询专业医生/心理咨询师」 4. 绝不编造医疗数据、药物剂量、具体治疗方案 5. 涉及医疗问题时,始终建议咨询专业医生 From a90423aaf1e54822b96c04d3b53bee4704b79757 Mon Sep 17 00:00:00 2001 From: Koishi Date: Sat, 7 Mar 2026 21:16:03 +0800 Subject: [PATCH 06/23] chore: update avatars --- frontend/src/assets/ai_avatar.png | 3 +++ frontend/src/assets/avatar.png | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) create mode 100644 frontend/src/assets/ai_avatar.png diff --git a/frontend/src/assets/ai_avatar.png b/frontend/src/assets/ai_avatar.png new file mode 100644 index 00000000..d23ac7b4 --- /dev/null +++ b/frontend/src/assets/ai_avatar.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d756bc027365668ea3d782ce1b7ede3cdf8b86cbbb5b57e3944e3143b2d70b1b +size 6901270 diff --git a/frontend/src/assets/avatar.png b/frontend/src/assets/avatar.png index e326af56..91dc7370 100644 --- a/frontend/src/assets/avatar.png +++ b/frontend/src/assets/avatar.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:92fb120be29832ccd0919bebb788d4cb35f214781d0f719854ec43780d67b5a4 -size 1831584 +oid sha256:c411dd20f53ff2f2c1aa8f3b839191e812549f08b02a77bd7d35e68c2285195a +size 5466723 From 09e8b55f8d266abebcdb237b5e9405f2ebc2f2b3 Mon Sep 17 00:00:00 2001 From: Koishi Date: Sat, 7 Mar 2026 21:17:03 +0800 Subject: [PATCH 07/23] feat: add edit/delete UI for questions, answers, and comments Replace hot tab with my collections. Add inline edit/delete controls for questions and answers (author + admin), delete for comments. Co-Authored-By: Claude Opus 4.6 --- .../src/components/overlay/CommunityPanel.vue | 260 ++++++++++++++++-- 1 file changed, 233 insertions(+), 27 deletions(-) diff --git a/frontend/src/components/overlay/CommunityPanel.vue b/frontend/src/components/overlay/CommunityPanel.vue index 07b26f45..f9d01b46 100644 --- a/frontend/src/components/overlay/CommunityPanel.vue +++ b/frontend/src/components/overlay/CommunityPanel.vue @@ -17,10 +17,10 @@ 专业问答 @@ -105,16 +105,30 @@
-

{{ selectedDetail.title }}

-
- - {{ selectedDetail.author.nickname }} - {{ selectedDetail.author.display_tag }} -
-
- {{ tag.name }} -
-
{{ selectedDetail.content }}
+