docs(website): 添加简记memo产品原型和UI设计规范文档
- 新增「简记memo」一体化小程序产品原型设计文档 - 新增简记memo完整版UI视觉设计规范和界面细节 - 添加IDEA项目配置文件.gitignore - 创建404页面HTML文件,包含响应式布局和错误提示 - 添加关于页面HTML文件,展示品牌介绍和团队信息 - 实现AES加解密工具函数,支持请求体加密 - 添加用户协议页面基础框架
This commit is contained in:
+507
@@ -0,0 +1,507 @@
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user