feat: 第五轮开发 - 14项未来路线图功能完整实现

W1-W14 全部完成:
- W1: 消息搜索 (ILIKE全文检索 + SearchModal)
- W2: 对话导出 (JSON/Markdown/TXT三格式)
- W3: 记忆时间线 DevTools 可视化
- W4: 通知推送系统 (WebSocket + Browser Notification API)
- W5: 定时提醒 (30s轮询 + 重复提醒 + WebSocket推送)
- W6: 每日简报 (08:00自动生成: 天气+新闻+提醒+AI摘要)
- W7: IoT场景自动化 (规则引擎 10s轮询 + 条件评估 + 场景执行)
- W8: 语音输入 (浏览器 Speech Recognition API)
- W9: STT服务 (voice-service + whisper.cpp)
- W10: TTS服务 (浏览器 Speech Synthesis + edge-tts三档回退)
- W11: 文件管理 (上传/下载/缩略图/纯Go bilinear缩放)
- W12: 知识库RAG (PostgreSQL tsvector + 文档分块 + 检索)
- W13: 多模态 (图片上传+分析: Vision API + 本地Go分析回退)
- W14: PWA (Service Worker + 离线页 + install prompt)

总计: 6个Go微服务 + 10+前端组件 + 10+ PostgreSQL表 + 4个后台调度器
This commit is contained in:
2026-05-19 12:01:09 +08:00
parent 78e3f450c2
commit bcf4d4e621
69 changed files with 14599 additions and 150 deletions
@@ -0,0 +1,709 @@
package handler
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/yourname/cyrene-ai/gateway/internal/config"
"github.com/yourname/cyrene-ai/gateway/internal/middleware"
"github.com/yourname/cyrene-ai/gateway/internal/store"
"github.com/yourname/cyrene-ai/gateway/internal/ws"
)
// BriefingHandler 每日简报处理器
type BriefingHandler struct {
cfg *config.Config
hub *ws.Hub
briefingStore *store.BriefingStore
reminderStore *store.ReminderStore
httpClient *http.Client
}
// NewBriefingHandler 创建简报处理器
func NewBriefingHandler(cfg *config.Config, hub *ws.Hub, bs *store.BriefingStore, rs *store.ReminderStore) *BriefingHandler {
return &BriefingHandler{
cfg: cfg,
hub: hub,
briefingStore: bs,
reminderStore: rs,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// GenerateBriefingRequest 手动生成简报请求体
type GenerateBriefingRequest struct {
UserID string `json:"user_id" binding:"required"`
}
// GetBriefing 获取指定日期简报
// GET /api/v1/briefings?user_id=xxx&date=2024-01-01
func (h *BriefingHandler) GetBriefing(c *gin.Context) {
authUserID := middleware.GetUserID(c)
userID := c.Query("user_id")
date := c.Query("date")
if !strings.HasPrefix(authUserID, "admin_") || userID == "" {
userID = authUserID
}
if userID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少user_id参数"})
return
}
if date == "" {
date = time.Now().Format("2006-01-02")
}
briefing, err := h.briefingStore.GetBriefingByDate(userID, date)
if err != nil {
log.Printf("[briefing] 查询简报失败: user=%s date=%s err=%v", userID, date, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询简报失败: " + err.Error()})
return
}
if briefing == nil {
c.JSON(http.StatusOK, gin.H{
"briefing": nil,
"message": "当日简报尚未生成",
})
return
}
c.JSON(http.StatusOK, gin.H{"briefing": briefing})
}
// GetLatestBriefings 获取最近简报列表
// GET /api/v1/briefings/latest?user_id=xxx&limit=7
func (h *BriefingHandler) GetLatestBriefings(c *gin.Context) {
authUserID := middleware.GetUserID(c)
userID := c.Query("user_id")
if !strings.HasPrefix(authUserID, "admin_") || userID == "" {
userID = authUserID
}
if userID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少user_id参数"})
return
}
limit := 7
if l := c.Query("limit"); l != "" {
if parsed, err := parseInt(l); err == nil && parsed > 0 && parsed <= 30 {
limit = parsed
}
}
briefings, err := h.briefingStore.GetLatestBriefings(userID, limit)
if err != nil {
log.Printf("[briefing] 查询简报列表失败: user=%s err=%v", userID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询简报列表失败: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"briefings": briefings,
"total": len(briefings),
})
}
// Generate 手动触发生成今日简报
// POST /api/v1/briefings/generate
func (h *BriefingHandler) Generate(c *gin.Context) {
authUserID := middleware.GetUserID(c)
var req GenerateBriefingRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效: " + err.Error()})
return
}
// 非管理员只能为自己生成
if !strings.HasPrefix(authUserID, "admin_") {
req.UserID = authUserID
}
if req.UserID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少user_id"})
return
}
result, err := h.GenerateDailyBriefing(req.UserID)
if err != nil {
log.Printf("[briefing] 生成简报失败: user=%s err=%v", req.UserID, err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "生成简报失败: " + err.Error(),
"success": false,
})
return
}
// 生成后推送通知
h.pushBriefingNotification(req.UserID, result)
c.JSON(http.StatusOK, gin.H{
"success": true,
"briefing": result,
"message": "简报已生成并推送",
})
}
// GenerateDailyBriefing 生成每日简报(核心逻辑)
func (h *BriefingHandler) GenerateDailyBriefing(userID string) (*store.Briefing, error) {
today := time.Now().Format("2006-01-02")
briefing := &store.Briefing{
ID: "brief_" + generateID(),
UserID: userID,
Date: today,
Status: "pending",
Weather: &store.WeatherData{},
News: []store.NewsItem{},
Reminders: []store.BriefReminder{},
}
// 1. 获取天气数据
log.Printf("[briefing] 获取天气数据...")
weather, err := h.fetchWeather("Shanghai")
if err != nil {
log.Printf("[briefing] 天气获取失败 (降级): %v", err)
weather = &store.WeatherData{
Location: "未知",
Temp: 0,
Condition: "获取天气失败",
Icon: "❓",
}
}
briefing.Weather = weather
log.Printf("[briefing] 天气: %s %.1f°C %s", weather.Location, weather.Temp, weather.Condition)
// 2. 获取今日待办提醒
log.Printf("[briefing] 获取待办提醒...")
reminders, err := h.reminderStore.GetRemindersByUser(userID, "pending", 10, 0)
if err != nil {
log.Printf("[briefing] 获取提醒失败: %v", err)
} else {
now := time.Now()
endOfDay := time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location())
for _, r := range reminders {
if r.RemindAt.Before(endOfDay) || r.RemindAt.Equal(endOfDay) {
briefing.Reminders = append(briefing.Reminders, store.BriefReminder{
ID: r.ID,
Title: r.Title,
RemindAt: r.RemindAt.Format(time.RFC3339),
})
}
}
}
log.Printf("[briefing] 今日待办: %d 项", len(briefing.Reminders))
// 3. 获取新闻摘要(通过 tool-engine web_search
log.Printf("[briefing] 获取新闻摘要...")
news, err := h.fetchNews()
if err != nil {
log.Printf("[briefing] 新闻获取失败 (降级): %v", err)
}
briefing.News = news
log.Printf("[briefing] 新闻: %d 条", len(news))
// 4. 生成 AI 摘要
log.Printf("[briefing] 生成 AI 摘要...")
summary, err := h.generateAISummary(briefing)
if err != nil {
log.Printf("[briefing] AI 摘要生成失败 (降级): %v", err)
summary = h.buildFallbackSummary(briefing)
}
briefing.Summary = summary
// 5. 标记为已生成
now := time.Now()
briefing.Status = "generated"
briefing.GeneratedAt = &now
// 6. 持久化
if err := h.briefingStore.CreateOrUpdateBriefing(briefing); err != nil {
return nil, fmt.Errorf("保存简报失败: %w", err)
}
log.Printf("[briefing] 简报已生成: user=%s date=%s", userID, today)
return briefing, nil
}
// fetchWeather 通过 wttr.in API 获取天气数据
func (h *BriefingHandler) fetchWeather(location string) (*store.WeatherData, error) {
url := fmt.Sprintf("https://wttr.in/%s?format=j1", location)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("构建天气请求失败: %w", err)
}
req.Header.Set("User-Agent", "Cyrene-AI/1.0")
resp, err := h.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("请求天气API失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("天气API返回状态码 %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取天气响应失败: %w", err)
}
// 解析 wttr.in JSON 响应
var wttrResp struct {
CurrentCondition []struct {
TempC string `json:"temp_C"`
WeatherDesc []struct {
Value string `json:"value"`
} `json:"weatherDesc"`
WeatherIconURL []struct {
Value string `json:"value"`
} `json:"weatherIconUrl"`
} `json:"current_condition"`
NearestArea []struct {
AreaName []struct {
Value string `json:"value"`
} `json:"areaName"`
} `json:"nearest_area"`
}
if err := json.Unmarshal(body, &wttrResp); err != nil {
return nil, fmt.Errorf("解析天气数据失败: %w", err)
}
wd := &store.WeatherData{
Location: location,
}
if len(wttrResp.NearestArea) > 0 && len(wttrResp.NearestArea[0].AreaName) > 0 {
wd.Location = wttrResp.NearestArea[0].AreaName[0].Value
}
if len(wttrResp.CurrentCondition) > 0 {
cc := wttrResp.CurrentCondition[0]
wd.Temp = parseFloat(cc.TempC)
if len(cc.WeatherDesc) > 0 {
wd.Condition = cc.WeatherDesc[0].Value
}
// 根据天气描述转 emoji
wd.Icon = weatherEmoji(wd.Condition)
}
return wd, nil
}
// fetchNews 通过 tool-engine web_search 搜索今日新闻
func (h *BriefingHandler) fetchNews() ([]store.NewsItem, error) {
if h.cfg.ToolEngineURL == "" {
return nil, fmt.Errorf("ToolEngine URL 未配置")
}
today := time.Now().Format("2006年01月02日")
query := fmt.Sprintf("%s 今日要闻 热点新闻", today)
reqBody, _ := json.Marshal(map[string]interface{}{
"arguments": map[string]interface{}{
"query": query,
"limit": 5,
},
})
url := fmt.Sprintf("%s/api/v1/tools/web_search/execute", h.cfg.ToolEngineURL)
req, err := http.NewRequest("POST", url, bytes.NewReader(reqBody))
if err != nil {
return nil, fmt.Errorf("构建新闻搜索请求失败: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := h.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("请求新闻搜索失败: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取新闻搜索结果失败: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("新闻搜索返回状态码 %d: %s", resp.StatusCode, string(body))
}
// 解析 tool-engine 单个工具执行响应: {id, output, error?}
var result struct {
ID string `json:"id"`
Output string `json:"output"`
Error string `json:"error,omitempty"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("解析新闻搜索结果失败: %w", err)
}
if result.Error != "" {
log.Printf("[briefing] 新闻搜索失败: %s", result.Error)
// 返回降级新闻
return []store.NewsItem{
{
Title: "未能获取今日新闻",
URL: "",
Source: "系统",
Summary: "新闻搜索服务暂时不可用,请稍后再试。",
},
}, nil
}
var news []store.NewsItem
// 尝试解析搜索结果为结构化数据
var searchResults []struct {
Title string `json:"title"`
URL string `json:"url"`
Snippet string `json:"snippet"`
Source string `json:"source"`
}
if err := json.Unmarshal([]byte(result.Output), &searchResults); err != nil {
// 如果不是 JSON 数组,当做纯文本处理
news = append(news, store.NewsItem{
Title: "今日新闻",
URL: "",
Source: "搜索引擎",
Summary: truncateStr(result.Output, 200),
})
} else {
for _, sr := range searchResults {
news = append(news, store.NewsItem{
Title: sr.Title,
URL: sr.URL,
Source: sr.Source,
Summary: sr.Snippet,
})
}
}
if len(news) == 0 {
news = []store.NewsItem{
{
Title: "未能获取今日新闻",
URL: "",
Source: "系统",
Summary: "新闻搜索服务暂时不可用,请稍后再试。",
},
}
}
// 限制最多 5 条
if len(news) > 5 {
news = news[:5]
}
return news, nil
}
// generateAISummary 通过 AI-Core 生成人性化摘要
func (h *BriefingHandler) generateAISummary(b *store.Briefing) (string, error) {
if h.cfg.AICoreURL == "" {
return "", fmt.Errorf("AI-Core URL 未配置")
}
// 构建提示词
prompt := h.buildSummaryPrompt(b)
reqBody, _ := json.Marshal(map[string]interface{}{
"messages": []map[string]interface{}{
{
"role": "system",
"content": "你是昔涟,一个温柔贴心的AI助手。请用温暖、亲切的语气回复,像朋友一样关心用户。回复使用中文。",
},
{
"role": "user",
"content": prompt,
},
},
"max_tokens": 500,
"temperature": 0.7,
})
url := fmt.Sprintf("%s/api/v1/chat/completions", h.cfg.AICoreURL)
req, err := http.NewRequest("POST", url, bytes.NewReader(reqBody))
if err != nil {
return "", fmt.Errorf("构建 AI 请求失败: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := h.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("请求 AI-Core 失败: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("读取 AI 响应失败: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("AI-Core 返回状态码 %d: %s", resp.StatusCode, string(body))
}
// 解析 OpenAI 兼容响应
var aiResp struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
if err := json.Unmarshal(body, &aiResp); err != nil {
return "", fmt.Errorf("解析 AI 响应失败: %w", err)
}
if len(aiResp.Choices) == 0 {
return "", fmt.Errorf("AI 返回空响应")
}
return aiResp.Choices[0].Message.Content, nil
}
// buildSummaryPrompt 构建 AI 摘要提示词
func (h *BriefingHandler) buildSummaryPrompt(b *store.Briefing) string {
var sb strings.Builder
today := time.Now().Format("2006年01月02日")
sb.WriteString(fmt.Sprintf("今天是%s。请根据以下信息,用昔涟温柔的语气为用户生成一份简短的每日简报(控制在200字以内):\n\n", today))
// 天气
if b.Weather != nil && b.Weather.Condition != "" {
sb.WriteString(fmt.Sprintf("☁️ 天气:%s%.0f°C%s\n", b.Weather.Location, b.Weather.Temp, b.Weather.Condition))
}
// 待办
if len(b.Reminders) > 0 {
sb.WriteString("📋 今日待办:\n")
for _, r := range b.Reminders {
sb.WriteString(fmt.Sprintf(" - %s\n", r.Title))
}
}
// 新闻
if len(b.News) > 0 {
sb.WriteString("📰 今日新闻:\n")
for _, n := range b.News {
if n.Summary != "" {
sb.WriteString(fmt.Sprintf(" - %s: %s\n", n.Title, n.Summary))
} else {
sb.WriteString(fmt.Sprintf(" - %s\n", n.Title))
}
}
}
sb.WriteString("\n请用昔涟的语气回复,包含:1) 温馨问候 2) 天气提醒 3) 待办提醒 4) 新闻简要 5) 结语祝福。简洁自然即可。")
return sb.String()
}
// buildFallbackSummary 降级摘要(不依赖 AI
func (h *BriefingHandler) buildFallbackSummary(b *store.Briefing) string {
today := time.Now().Format("2006年01月02日")
var sb strings.Builder
sb.WriteString(fmt.Sprintf("早上好!今天是%s ☀️\n\n", today))
if b.Weather != nil && b.Weather.Condition != "" {
sb.WriteString(fmt.Sprintf("今日%s天气:%s%.0f°C。", b.Weather.Location, b.Weather.Condition, b.Weather.Temp))
if b.Weather.Temp < 10 {
sb.WriteString("天气有点凉,记得多穿件衣服哦~")
} else if b.Weather.Temp > 30 {
sb.WriteString("天气比较热,注意防暑降温哦~")
} else {
sb.WriteString("天气不错,适合出门走走呢~")
}
sb.WriteString("\n\n")
}
if len(b.Reminders) > 0 {
sb.WriteString(fmt.Sprintf("你今天有 %d 项待办事项,记得按时完成哦!\n", len(b.Reminders)))
} else {
sb.WriteString("今天没有待办事项,可以轻松一下~\n")
}
if len(b.News) > 0 && b.News[0].Title != "未能获取今日新闻" {
sb.WriteString(fmt.Sprintf("\n今日热点:%s。", b.News[0].Title))
}
sb.WriteString("\n\n祝你度过美好的一天!🌸")
return sb.String()
}
// pushBriefingNotification 推送简报通知到用户
func (h *BriefingHandler) pushBriefingNotification(userID string, b *store.Briefing) {
bodyPreview := truncateStr(b.Summary, 100)
notif := &ws.NotificationInfo{
ID: "briefing_" + b.ID,
Type: "info",
Title: fmt.Sprintf("📋 今日简报 (%s)", b.Date),
Body: bodyPreview,
Timestamp: time.Now().UTC().Format(time.RFC3339),
Data: map[string]interface{}{
"briefing_id": b.ID,
"date": b.Date,
"type": "daily_briefing",
},
}
msg := ws.ServerMessage{
Type: "notification",
MessageID: "briefing_" + b.ID,
Timestamp: time.Now().UnixMilli(),
Notification: notif,
}
data, err := json.Marshal(msg)
if err != nil {
log.Printf("[briefing] 序列化简报通知失败: %v", err)
return
}
h.hub.SendToUser(userID, data)
// 更新简报状态为已送达
now := time.Now()
b.Status = "delivered"
b.DeliveredAt = &now
if err := h.briefingStore.CreateOrUpdateBriefing(b); err != nil {
log.Printf("[briefing] 更新简报送达状态失败: %v", err)
}
log.Printf("[briefing] 简报通知已推送: user=%s date=%s", userID, b.Date)
}
// StartBriefingScheduler 启动简报调度器
// briefingTime 格式: "HH:MM",默认 "08:00"
func StartBriefingScheduler(handler *BriefingHandler, briefingStore *store.BriefingStore, briefingTime string) {
if briefingTime == "" {
briefingTime = "08:00"
}
go func() {
// 每 30 秒检查一次是否到达简报时间
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
log.Printf("[BriefingScheduler] 简报调度器已启动 (简报时间: %s)", briefingTime)
// 记录今天是否已触发
lastTriggeredDate := ""
for range ticker.C {
now := time.Now()
currentTime := now.Format("15:04")
currentDate := now.Format("2006-01-02")
// 检查是否到达简报时间且今天尚未触发
if currentTime == briefingTime && currentDate != lastTriggeredDate {
log.Printf("[BriefingScheduler] 触发每日简报生成: %s", currentDate)
lastTriggeredDate = currentDate
// 获取所有用户
users, err := briefingStore.GetAllUsers()
if err != nil {
log.Printf("[BriefingScheduler] 获取用户列表失败: %v", err)
continue
}
// 如果没有从 reminders 表获取到用户,也尝试从 briefings 表获取
if len(users) == 0 {
users, _ = briefingStore.GetUsersWithBriefings()
}
if len(users) == 0 {
log.Println("[BriefingScheduler] 没有找到用户,跳过简报生成")
continue
}
for _, userID := range users {
log.Printf("[BriefingScheduler] 为用户 %s 生成简报...", userID)
result, err := handler.GenerateDailyBriefing(userID)
if err != nil {
log.Printf("[BriefingScheduler] 生成简报失败: user=%s err=%v", userID, err)
continue
}
handler.pushBriefingNotification(userID, result)
}
log.Printf("[BriefingScheduler] 每日简报已生成完毕,共 %d 个用户", len(users))
}
}
}()
}
// ========== 辅助函数 ==========
// weatherEmoji 根据天气描述返回对应 emoji
func weatherEmoji(condition string) string {
c := strings.ToLower(condition)
switch {
case strings.Contains(c, "sunny") || strings.Contains(c, "clear") || strings.Contains(c, "晴"):
return "☀️"
case strings.Contains(c, "partly cloudy") || strings.Contains(c, "多云"):
return "⛅"
case strings.Contains(c, "cloudy") || strings.Contains(c, "阴"):
return "☁️"
case strings.Contains(c, "rain") || strings.Contains(c, "drizzle") || strings.Contains(c, "雨"):
return "🌧️"
case strings.Contains(c, "thunder") || strings.Contains(c, "雷"):
return "⛈️"
case strings.Contains(c, "snow") || strings.Contains(c, "雪"):
return "❄️"
case strings.Contains(c, "fog") || strings.Contains(c, "mist") || strings.Contains(c, "雾"):
return "🌫️"
case strings.Contains(c, "wind") || strings.Contains(c, "风"):
return "💨"
default:
return "🌤️"
}
}
// parseFloat 安全解析浮点数
func parseFloat(s string) float64 {
var f float64
fmt.Sscanf(s, "%f", &f)
return f
}
// parseInt 安全解析整数
func parseInt(s string) (int, error) {
var n int
_, err := fmt.Sscanf(s, "%d", &n)
return n, err
}
// truncateStr 截断字符串
func truncateStr(s string, maxLen int) string {
runes := []rune(s)
if len(runes) <= maxLen {
return s
}
return string(runes[:maxLen]) + "..."
}