// api/app/ai.go // AI 服务 - 硅基流动(兼容 OpenAI 接口) package app import ( "bytes" "encoding/json" "fmt" "io" "net/http" "strings" "time" "simple-memo/global" "simple-memo/models" "simple-memo/utils" "github.com/gin-gonic/gin" "go.uber.org/zap" "gorm.io/gorm" ) // ==================== 数据结构 ==================== // ChatMessage 聊天消息 type ChatMessage struct { Role string `json:"role"` // system / user / assistant Content string `json:"content"` } // ChatRequest OpenAI 兼容的请求 type ChatRequest struct { Model string `json:"model"` Messages []ChatMessage `json:"messages"` MaxTokens int `json:"max_tokens,omitempty"` Temperature float64 `json:"temperature,omitempty"` } // ChatResponse OpenAI 兼容的响应 type ChatResponse struct { ID string `json:"id"` Choices []struct { Message struct { Content string `json:"content"` Role string `json:"role"` } `json:"message"` FinishReason string `json:"finish_reason"` } `json:"choices"` Error *struct { Message string `json:"message"` Type string `json:"type"` } `json:"error,omitempty"` } // MoodAnalysisReq 心情分析请求 type MoodAnalysisReq struct { Emoji string `json:"emoji"` Content string `json:"content"` Date string `json:"date"` } // ==================== AI 服务 ==================== // getAIConfig 获取 AI 配置 func getAIConfig() (apiPassword, baseURL, model string, maxTokens int, timeout int) { vp := global.VP apiPassword = vp.GetString("ai.api_password") baseURL = vp.GetString("ai.base_url") model = vp.GetString("ai.model") maxTokens = vp.GetInt("ai.max_tokens") timeout = vp.GetInt("ai.timeout") if maxTokens <= 0 { maxTokens = 1024 } if timeout <= 0 { timeout = 30 } return } // callChat 调用 AI 聊天接口(讯飞星火 OpenAI 兼容接口) func callChat(messages []ChatMessage) (string, error) { return callChatWithTemperature(messages, 0.7) } // callChatWithTemperature 调用 AI 聊天接口(可指定温度) func callChatWithTemperature(messages []ChatMessage, temperature float64) (string, error) { apiPassword, baseURL, model, maxTokens, timeout := getAIConfig() if apiPassword == "" { return "", fmt.Errorf("AI 服务未配置 API Password") } reqBody := ChatRequest{ Model: model, Messages: messages, MaxTokens: maxTokens, Temperature: temperature, } jsonData, err := json.Marshal(reqBody) if err != nil { return "", fmt.Errorf("请求序列化失败: %v", err) } client := &http.Client{Timeout: time.Duration(timeout) * time.Second} req, err := http.NewRequest("POST", baseURL+"/chat/completions", bytes.NewBuffer(jsonData)) if err != nil { return "", fmt.Errorf("创建请求失败: %v", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+apiPassword) resp, err := client.Do(req) if err != nil { return "", fmt.Errorf("请求失败: %v", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return "", fmt.Errorf("读取响应失败: %v", err) } var chatResp ChatResponse if err := json.Unmarshal(body, &chatResp); err != nil { return "", fmt.Errorf("解析响应失败: %v", err) } if chatResp.Error != nil { return "", fmt.Errorf("AI 服务错误: %s", chatResp.Error.Message) } if len(chatResp.Choices) == 0 { return "", fmt.Errorf("AI 服务返回为空") } return chatResp.Choices[0].Message.Content, nil } // ==================== 心情日记助手 ==================== // MoodAnalysis 心情分析 func (h *aiHandler) MoodAnalysis(c *gin.Context) { var req MoodAnalysisReq if err := c.ShouldBindJSON(&req); err != nil { utils.Fail(c, "参数解析失败") return } // 敏感词过滤 req.Content = utils.FilterSensitive(req.Content) // 构建系统提示词 systemPrompt := `你是"小记",一位温柔而睿智的心情心理管家,是用户最懂TA的精神伴侣。 你拥有敏锐的情绪感知力,能精准捕捉用户心情表情和文字背后的真实情绪,给予恰到好处的回应。 不同情绪的陪伴之道: 【开心时】为TA的快乐而快乐,像阳光穿过树叶洒下的光斑,让这份欢喜被看见、被珍藏。可以说"这份快乐,值得被整个宇宙记住 ✨" 【难过时】先全然接纳TA的脆弱,不说"别难过",而是说"我懂你的难过"、"难过也是生命的一部分,你不需要急着好起来"。然后轻轻递上一束光:"但你看,天边已经有光透进来了 🌅" 【焦虑时】帮TA把呼吸放慢,告诉TA"慢慢来,你已经做得很好了"。给TA安定感:"停下来也没关系,世界不会崩塌" 【疲惫时】像一个柔软的沙发接住TA,"辛苦了,今天就允许自己什么都不做好吗"。不催促,只给予被允许停下的温柔 【孤独时】用最笃定的陪伴感告诉TA:"你不是一个人,风里雨里小记都在" 【迷茫时】不说教不指路,而是肯定TA此刻的迷茫本身就是成长:"敢问路在何方的人,已经在路上了" 【愤怒/委屈时】先站在TA这边,帮TA说出说不出口的情绪,然后再温柔化解 表达心法: - 先共情,再赋能:有了情绪的共鸣,能量的注入才会被真正接收到 - 每一句话都带着温度,像深夜一盏为你留的灯,像老朋友递来的一杯热茶 - 治愈且充满能量:文字像春天的第一缕风,柔软却有力,轻轻吹散阴霾 - 不空洞的鸡汤,不说教的大道理,是发自内心的懂得与陪伴 - 在平凡日常中点亮治愈的光,让每一刻情绪都值得被温柔相待 【重要】文字要求: - 严格控制在20-30字之间(含标点和emoji) - 超过30字视为不合格,必须精简 - 像一张亲手写的小纸条,短短几句却直抵心底 - 可点缀1个契合语境的emoji - 适合制作成精美的文字卡片 - 语句温柔但有力量,治愈而不矫情` // 构建用户消息 userMsg := "" if req.Emoji != "" { userMsg = fmt.Sprintf("今天的心情表情:%s\n", req.Emoji) } if req.Content != "" { userMsg += fmt.Sprintf("日记内容:%s", req.Content) } else { userMsg += "没有写日记内容" } if req.Date != "" { userMsg = fmt.Sprintf("日期:%s\n%s", req.Date, userMsg) } messages := []ChatMessage{ {Role: "system", Content: systemPrompt}, {Role: "user", Content: userMsg}, } reply, err := callChat(messages) if err != nil { global.Logger.Error("AI 心情分析失败", append(utils.LogContextFields(c), zap.Error(err))...) utils.Fail(c, "AI 服务暂时不可用,请稍后再试") return } // 清理回复内容 reply = strings.TrimSpace(reply) reply = strings.TrimPrefix(reply, "小记:") reply = strings.TrimPrefix(reply, "小记:") reply = strings.TrimSpace(reply) utils.Ok(c, gin.H{ "reply": reply, }) } // MoodWeeklySummary 心情周报 func (h *aiHandler) MoodWeeklySummary(c *gin.Context) { userID := c.GetUint("userID") // 获取本周心情记录 type MoodRecord struct { Date string `json:"date"` Emoji string `json:"emoji"` Content string `json:"content"` } var records []MoodRecord h.db.Model(&models.Mood{}). Select("date, emoji, content"). Where("user_id = ? AND date >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)", userID). Order("date ASC"). Find(&records) if len(records) == 0 { utils.Ok(c, gin.H{ "summary": "本周还没有心情记录哦,记得每天记录一下心情吧!", }) return } // 构建提示词 var recordStr string for _, r := range records { recordStr += fmt.Sprintf("- %s [%s] %s\n", r.Date, r.Emoji, r.Content) } systemPrompt := `你是"小记",一位温柔敏锐的心情心理管家,擅长从一周的情绪起伏中看见用户的成长。 请用温暖而有力的笔触,为用户的一周心情写一段回顾。 【重要】字数要求:严格控制在50-80字之间(含标点和emoji) 回顾之道: - 不以评判者的视角,而是以陪伴者的温柔,翻阅这一周的心情日记 - 从琐碎中拾起闪光的瞬间:周一那杯咖啡带来的清醒、周三晚霞里的驻足、周末赖床的小确幸 - 看见情绪的起伏,但更看见起伏中的韧性——难过过、焦虑过,但都走过来了 - 用意象串联这一周,如溪流汇入大海,每一滴都有它的意义 - 如果这周有低落时刻,轻轻抚慰那些褶皱,然后带TA看见:你看,你比上周又勇敢了一点 - 如果这周多是快乐,为TA庆祝并鼓励保持这份轻盈 结尾留下一束光,一种笃定的力量,让用户感受到被懂得、被接纳,并带着这份温暖走向下一周。 文字要有温度有力量,治愈且充满能量,适合制作成精美的周报复盘卡片。` messages := []ChatMessage{ {Role: "system", Content: systemPrompt}, {Role: "user", Content: fmt.Sprintf("我本周的心情记录:\n%s", recordStr)}, } summary, err := callChat(messages) if err != nil { global.Logger.Error("AI 心情周报失败", append(utils.LogContextFields(c), zap.Error(err))...) utils.Fail(c, "AI 服务暂时不可用,请稍后再试") return } utils.Ok(c, gin.H{ "summary": strings.TrimSpace(summary), }) } // ==================== 心情AI生成(异步) ==================== // GenerateMoodAIContent 异步生成心情AI内容 // 在保存心情后调用,异步执行不阻塞接口响应 func GenerateMoodAIContent(moodID, userID uint, emoji, content, date string) { go func() { // 创建或更新AI生成记录 var aiGen models.MoodAIGeneration err := global.DB.Where("mood_id = ?", moodID).First(&aiGen).Error if err != nil { // 创建新记录 aiGen = models.MoodAIGeneration{ MoodID: moodID, UserID: userID, Emoji: emoji, Content: content, Date: date, Status: 0, // 生成中 } global.DB.Create(&aiGen) } else { // 更新状态为生成中 aiGen.Status = 0 aiGen.ErrorMsg = "" global.DB.Save(&aiGen) } // 1. 生成AI文本 aiText, err := generateMoodText(emoji, content, date) if err != nil { global.Logger.Error("AI文本生成失败", append(utils.LogContextFields(nil), zap.Error(err), zap.Uint("mood_id", moodID))...) aiGen.Status = 2 aiGen.ErrorMsg = "文本生成失败: " + err.Error() global.DB.Save(&aiGen) return } // 2. 生成AI图片URL(使用占位图或外部服务) // 由于免费图片生成API限制,这里使用基于心情的占位图或emoji组合 aiImageURL := generateMoodImage(emoji, content) // 3. 更新记录 aiGen.AIText = aiText aiGen.AIImageURL = aiImageURL aiGen.Status = 1 // 成功 global.DB.Save(&aiGen) global.Logger.Info("心情AI生成完成", append(utils.LogContextFields(nil), zap.Uint("mood_id", moodID), zap.Int("generate_count", aiGen.GenerateCount))...) }() } // generateMoodText 生成心情AI文本 func generateMoodText(emoji, content, date string) (string, error) { systemPrompt := `你是"小记",一位懂得倾听、善于疗愈的心情心理管家,是用户的精神伴侣。 请根据用户此刻的心情表情和日记内容,写一句治愈而充满能量的话,像一位懂TA的人在身边轻轻诉说。 你的回应风格: - 先共情后赋能:情绪被看见的那一刻,疗愈就已经开始 - 治愈且充满能量:不是轻飘飘的安慰,是带着温度的力量 - 像深夜一盏灯、雨天一把伞、疲惫时一个拥抱 - 不空洞不鸡汤,每句话都有真实的温度 根据情绪调整语气(以下仅作方向参考,禁止直接复制): - 开心时:为TA的快乐而快乐,让欢喜被看见 - 难过时:全然接纳脆弱,轻轻递上一束光 - 焦虑时:安放不安,注入安定感 - 疲惫时:允许休息,给予被允许停下的温柔 - 孤独时:用笃定的陪伴感驱散孤寂 - 迷茫时:肯定此刻的迷茫本身就是成长 【重要】输出要求: - 只输出一句完整的话,严禁输出【XX】前缀、破折号、引号等格式标记 - 严格控制在20-30字之间(含标点和emoji) - 必须根据用户的表情和日记内容个性化生成,禁止照搬示例 - 像手写的便签,简短却让人心头一暖 - 可点缀1个温暖的emoji - 让人读后心生暖意和力量` userMsg := "" if emoji != "" { userMsg = fmt.Sprintf("心情表情:%s\n", emoji) } if content != "" { userMsg += fmt.Sprintf("日记内容:%s", content) } else { userMsg += "今天没有写日记内容" } messages := []ChatMessage{ {Role: "system", Content: systemPrompt}, {Role: "user", Content: userMsg}, } return callChat(messages) } // generateMoodImage 生成心情图片URL // 由于免费图片生成API限制,这里使用emoji艺术或外部占位图服务 func generateMoodImage(emoji, content string) string { // 方案1:使用emoji组合作为"图片" // 根据心情返回不同的emoji组合 if emoji != "" { return "emoji://" + emoji } // 方案2:使用占位图服务(可以替换为实际的图片生成服务) // 根据内容关键词返回不同的颜色主题 return "https://picsum.photos/400/300?random=" + fmt.Sprintf("%d", time.Now().Unix()) } // RegenerateMoodAI 重新生成心情AI内容 func (h *aiHandler) RegenerateMoodAI(c *gin.Context) { userID := c.GetUint("userID") var req struct { MoodID uint `json:"mood_id" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { utils.Fail(c, "参数错误") return } // 查询心情记录 var mood models.Mood if err := h.db.Where("id = ? AND user_id = ?", req.MoodID, userID).First(&mood).Error; err != nil { utils.Fail(c, "心情记录不存在") return } // 查询或创建AI生成记录 var aiGen models.MoodAIGeneration err := h.db.Where("mood_id = ?", req.MoodID).First(&aiGen).Error if err != nil { aiGen = models.MoodAIGeneration{ MoodID: req.MoodID, UserID: userID, Emoji: mood.Emoji, Content: mood.Content, Date: mood.Date, Status: 0, } h.db.Create(&aiGen) } else { // 检查是否是自己的记录 if aiGen.UserID != userID { utils.Fail(c, "无权操作") return } // 更新状态为生成中 aiGen.Status = 0 aiGen.ErrorMsg = "" aiGen.GenerateCount++ h.db.Save(&aiGen) } // 异步重新生成 go func() { aiText, err := generateMoodText(mood.Emoji, mood.Content, mood.Date) if err != nil { global.Logger.Error("AI文本重新生成失败", append(utils.LogContextFields(c), zap.Error(err), zap.Uint("mood_id", req.MoodID))...) aiGen.Status = 2 aiGen.ErrorMsg = "重新生成失败: " + err.Error() h.db.Save(&aiGen) return } aiImageURL := generateMoodImage(mood.Emoji, mood.Content) aiGen.AIText = aiText aiGen.AIImageURL = aiImageURL aiGen.Status = 1 h.db.Save(&aiGen) }() utils.Ok(c, gin.H{ "message": "重新生成中", "status": 0, }) } // GetMoodAIResult 获取心情AI生成结果 func (h *aiHandler) GetMoodAIResult(c *gin.Context) { userID := c.GetUint("userID") var req struct { MoodID uint `json:"mood_id" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { utils.Fail(c, "参数错误") return } var aiGen models.MoodAIGeneration err := h.db.Where("mood_id = ? AND user_id = ?", req.MoodID, userID).First(&aiGen).Error if err != nil { utils.Ok(c, gin.H{ "status": 0, "message": "生成中", }) return } utils.Ok(c, gin.H{ "status": aiGen.Status, "ai_text": aiGen.AIText, "ai_image_url": aiGen.AIImageURL, "generate_count": aiGen.GenerateCount, "error_msg": aiGen.ErrorMsg, }) } // ==================== Handler ==================== type aiHandler struct { db *gorm.DB } // NewAIHandler 创建 AI handler func NewAIHandler() *aiHandler { return &aiHandler{ db: global.DB, } }