Files
sunct 3c3bf53ae4 docs(website): 添加简记memo产品原型和UI设计规范文档
- 新增「简记memo」一体化小程序产品原型设计文档
- 新增简记memo完整版UI视觉设计规范和界面细节
- 添加IDEA项目配置文件.gitignore
- 创建404页面HTML文件,包含响应式布局和错误提示
- 添加关于页面HTML文件,展示品牌介绍和团队信息
- 实现AES加解密工具函数,支持请求体加密
- 添加用户协议页面基础框架
2026-07-31 14:12:32 +08:00

508 lines
16 KiB
Go

// 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,
}
}