- 新增「简记memo」一体化小程序产品原型设计文档 - 新增简记memo完整版UI视觉设计规范和界面细节 - 添加IDEA项目配置文件.gitignore - 创建404页面HTML文件,包含响应式布局和错误提示 - 添加关于页面HTML文件,展示品牌介绍和团队信息 - 实现AES加解密工具函数,支持请求体加密 - 添加用户协议页面基础框架
508 lines
16 KiB
Go
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,
|
|
}
|
|
}
|