fix: 移除早间简报功能 + 修复多处"人机感"用语

- 删除 briefing_handler.go / briefing_store.go 及所有相关路由与配置
- 移除 Gateway Config 中 ToolEngineURL / BriefingTime 字段
- 移除 DevTools 中 gateway 的 TOOL_ENGINE_URL 环境变量
- webhook 错误提示从"AI 服务异常/暂不可用"改为昔涟自然口吻
- markdown 导出中昔涟头像从 🤖 改为 💫
- 后台思考提示词"系统会误解析"改为"我会误解析"

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 21:00:15 +08:00
parent 673ff752c5
commit d1b8f8e3b2
9 changed files with 6 additions and 1083 deletions
@@ -791,7 +791,7 @@ func (t *Thinker) buildThinkingSystemPrompt(personaConfig *persona.PersonaConfig
1. 反思部分用第三人称或自言自语的方式,不要直接对开拓者喊话。
2. 只有开拓者状态正常且真的有必要时,才在独立一行写【主动消息】标记,后面跟你要发给他的话。不要硬找话题。
3. 【主动消息】标记必须独占一行开头,内容直接对开拓者说话(用"你"称呼他),像主动找他聊天一样。
4. 如果你在反思中提到"主动消息"这个词但不打算发消息,不要使用【主动消息】这个带括号的标记——系统会误解析。
4. 如果你在反思中提到"主动消息"这个词但不打算发消息,不要使用【主动消息】这个带括号的标记——会误解析。
5. 2-4句话即可。`
case "silence":
+1 -15
View File
@@ -40,7 +40,6 @@ func main() {
// 初始化数据库持久化存储 (降级:连接失败不崩溃)
var sessionStore *store.SessionStore
var reminderStore *store.ReminderStore
var briefingStore *store.BriefingStore
var automationStore *store.AutomationStore
var fileStore *store.FileStore
var knowledgeStore *store.KnowledgeStore
@@ -103,13 +102,6 @@ func main() {
logger.Println("✅ 提醒持久化存储已启用 (PostgreSQL)")
}
// 初始化简报存储(复用同一数据库连接)
if bs, err := store.NewBriefingStore(s.DB()); err != nil {
logger.Printf("⚠ 简报存储初始化失败: %v", err)
} else {
briefingStore = bs
logger.Println("✅ 简报持久化存储已启用 (PostgreSQL)")
}
// 初始化自动化存储(复用同一数据库连接)
if as, err := store.NewAutomationStore(s.DB()); err != nil {
@@ -194,19 +186,13 @@ func main() {
logger.Println("[INFO] 思考调度配置文件已加载 (thinking_schedule.json)")
}
router.Setup(r, hub, cfg, sessionStore, reminderStore, briefingStore, automationStore, fileStore, ruleEngine, knowledgeStore, nil, db, modelConfigStore, thinkingScheduleStore)
router.Setup(r, hub, cfg, sessionStore, reminderStore, automationStore, fileStore, ruleEngine, knowledgeStore, nil, db, modelConfigStore, thinkingScheduleStore)
// 启动提醒调度器
if reminderStore != nil {
handler.StartReminderScheduler(reminderStore, hub)
}
// 启动简报调度器
if briefingStore != nil && reminderStore != nil {
briefingHandler := handler.NewBriefingHandler(cfg, hub, briefingStore, reminderStore)
handler.StartBriefingScheduler(briefingHandler, briefingStore, cfg.BriefingTime)
}
// 启动服务
srv := &http.Server{
Addr: ":" + cfg.Port,
@@ -50,9 +50,6 @@ type Config struct {
// Voice 语音识别服务
VoiceServiceURL string
// Tool-Engine 工具引擎服务
ToolEngineURL string
// LLM (透传给AI-CoreGateway可能也需要)
LLMAPIURL string
LLMAPIKey string
@@ -72,9 +69,6 @@ type Config struct {
// CORS 允许的 Origin 白名单
AllowedOrigins []string
// 每日简报时间 (HH:MM 格式)
BriefingTime string
}
// Load 从环境变量加载配置
@@ -129,8 +123,6 @@ func Load() *Config {
VoiceServiceURL: getEnv("VOICE_SERVICE_URL", "http://localhost:8093"),
ToolEngineURL: getEnv("TOOL_ENGINE_URL", "http://localhost:8092"),
LLMAPIURL: getEnv("LLM_API_URL", "https://api.openai.com/v1"),
LLMAPIKey: getEnv("LLM_API_KEY", ""),
LLMModel: getEnv("LLM_MODEL", "gpt-4o"),
@@ -143,7 +135,6 @@ func Load() *Config {
AllowedOrigins: parseAllowedOrigins(getEnv("ALLOWED_ORIGINS", "http://localhost:5173,http://localhost:5199,http://localhost:3000")),
BriefingTime: getEnv("BRIEFING_TIME", "08:00"),
}
}
@@ -1,713 +0,0 @@
package handler
import (
"bytes"
"encoding/json"
"fmt"
"io"
"github.com/yourname/cyrene-ai/pkg/logger"
"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 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 {
logger.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 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 {
logger.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 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 {
logger.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{},
CreatedAt: time.Now(),
}
// 1. 获取天气数据
logger.Printf("[briefing] 获取天气数据...")
weather, err := h.fetchWeather("Shanghai")
if err != nil {
logger.Printf("[briefing] 天气获取失败 (降级): %v", err)
weather = &store.WeatherData{
Location: "未知",
Temp: 0,
Condition: "获取天气失败",
Icon: "❓",
}
}
briefing.Weather = weather
logger.Printf("[briefing] 天气: %s %.1f°C %s", weather.Location, weather.Temp, weather.Condition)
// 2. 获取今日待办提醒
logger.Printf("[briefing] 获取待办提醒...")
reminders, err := h.reminderStore.GetRemindersByUser(userID, "pending", 10, 0)
if err != nil {
logger.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),
})
}
}
}
logger.Printf("[briefing] 今日待办: %d 项", len(briefing.Reminders))
// 3. 获取新闻摘要(通过 tool-engine web_search
logger.Printf("[briefing] 获取新闻摘要...")
news, err := h.fetchNews()
if err != nil {
logger.Printf("[briefing] 新闻获取失败 (降级): %v", err)
}
briefing.News = news
logger.Printf("[briefing] 新闻: %d 条", len(news))
// 4. 生成 AI 摘要
logger.Printf("[briefing] 生成 AI 摘要...")
summary, err := h.generateAISummary(briefing)
if err != nil {
logger.Printf("[briefing] AI 摘要生成失败 (降级): %v", err)
summary = h.buildFallbackSummary(briefing)
briefing.SummarySource = "fallback"
} else {
briefing.SummarySource = "ai"
}
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)
}
logger.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 != "" {
logger.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 {
logger.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 {
logger.Printf("[briefing] 更新简报送达状态失败: %v", err)
}
logger.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()
logger.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 {
logger.Printf("[BriefingScheduler] 触发每日简报生成: %s", currentDate)
lastTriggeredDate = currentDate
// 获取所有用户
users, err := briefingStore.GetAllUsers()
if err != nil {
logger.Printf("[BriefingScheduler] 获取用户列表失败: %v", err)
continue
}
// 如果没有从 reminders 表获取到用户,也尝试从 briefings 表获取
if len(users) == 0 {
users, _ = briefingStore.GetUsersWithBriefings()
}
if len(users) == 0 {
logger.Println("[BriefingScheduler] 没有找到用户,跳过简报生成")
continue
}
for _, userID := range users {
logger.Printf("[BriefingScheduler] 为用户 %s 生成简报...", userID)
result, err := handler.GenerateDailyBriefing(userID)
if err != nil {
logger.Printf("[BriefingScheduler] 生成简报失败: user=%s err=%v", userID, err)
continue
}
handler.pushBriefingNotification(userID, result)
}
logger.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]) + "..."
}
@@ -641,7 +641,7 @@ func (h *SessionHandler) exportMarkdown(c *gin.Context, session *store.Session,
case "user":
sb.WriteString(fmt.Sprintf("### 👤 用户 (%s)\n\n", timeStr))
case "assistant":
sb.WriteString(fmt.Sprintf("### 🤖 昔涟 (%s)\n\n", timeStr))
sb.WriteString(fmt.Sprintf("### 💫 昔涟 (%s)\n\n", timeStr))
case "system":
sb.WriteString(fmt.Sprintf("### ⚙️ 系统 (%s)\n\n", timeStr))
default:
@@ -96,7 +96,7 @@ func (h *WebhookHandler) HandleGenericWebhook(c *gin.Context) {
resp, err := h.callAICore(userID, sessionID, req.Message, mode, platform)
if err != nil {
logger.Printf("[webhook] AI-Core 调用失败 (platform=%s): %v", platform, err)
c.JSON(502, GenericWebhookResponse{Error: "AI 服务暂不可用: " + err.Error()})
c.JSON(502, GenericWebhookResponse{Error: "昔涟暂时无法回应喵...(请稍后再试)"})
return
}
@@ -247,7 +247,7 @@ func (h *WebhookHandler) HandleDiscordWebhook(c *gin.Context) {
logger.Printf("[webhook:discord] AI-Core 调用失败: %v", err)
c.JSON(200, DiscordResponse{
Type: 4,
Data: &DiscordResponseData{Content: "昔涟暂时无法回应喵...AI 服务异常: " + err.Error() + ""},
Data: &DiscordResponseData{Content: "昔涟暂时无法回应喵...遇到了一些问题,请稍后再试"},
})
return
}
+1 -10
View File
@@ -15,7 +15,7 @@ import (
)
// Setup 注册所有路由
func Setup(r *gin.Engine, hub *ws.Hub, cfg *config.Config, sessionStore *store.SessionStore, reminderStore *store.ReminderStore, briefingStore *store.BriefingStore, automationStore *store.AutomationStore, fileStore *store.FileStore, ruleEngine *engine.RuleEngine, knowledgeStore *store.KnowledgeStore, imageHandler *handler.ImageHandler, db interface{}, modelConfigStore *config.ModelsConfigStore, thinkingScheduleStore *config.ThinkingScheduleStore) {
func Setup(r *gin.Engine, hub *ws.Hub, cfg *config.Config, sessionStore *store.SessionStore, reminderStore *store.ReminderStore, automationStore *store.AutomationStore, fileStore *store.FileStore, ruleEngine *engine.RuleEngine, knowledgeStore *store.KnowledgeStore, imageHandler *handler.ImageHandler, db interface{}, modelConfigStore *config.ModelsConfigStore, thinkingScheduleStore *config.ThinkingScheduleStore) {
// 限流器
rateLimiter := middleware.NewRateLimiter(10, 20) // 每秒10个请求,突发20
@@ -31,7 +31,6 @@ func Setup(r *gin.Engine, hub *ws.Hub, cfg *config.Config, sessionStore *store.S
webhookHandler := handler.NewWebhookHandler(cfg, hub)
notificationHandler := handler.NewNotificationHandler(cfg, hub)
reminderHandler := handler.NewReminderHandler(reminderStore, hub)
briefingHandler := handler.NewBriefingHandler(cfg, hub, briefingStore, reminderStore)
voiceHandler := handler.NewVoiceHandler(cfg.VoiceServiceURL)
fileHandler := handler.NewFileHandler(fileStore)
automationHandler := handler.NewAutomationHandler(automationStore, ruleEngine)
@@ -115,14 +114,6 @@ func Setup(r *gin.Engine, hub *ws.Hub, cfg *config.Config, sessionStore *store.S
reminders.DELETE("/:id", reminderHandler.Delete) // DELETE /api/v1/reminders/:id
}
// 每日简报 (需要认证)
briefings := protected.Group("/briefings")
{
briefings.GET("", briefingHandler.GetBriefing) // GET /api/v1/briefings?user_id=xxx&date=2024-01-01
briefings.GET("/latest", briefingHandler.GetLatestBriefings) // GET /api/v1/briefings/latest?user_id=xxx&limit=7
briefings.POST("/generate", briefingHandler.Generate) // POST /api/v1/briefings/generate
}
// 语音识别 + TTS (需要认证)
voice := protected.Group("/voice")
{
@@ -1,331 +0,0 @@
package store
import (
"database/sql"
"encoding/json"
"fmt"
"github.com/yourname/cyrene-ai/pkg/logger"
"time"
)
// Briefing 每日简报模型
type Briefing struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Date string `json:"date"` // YYYY-MM-DD
Weather *WeatherData `json:"weather"`
News []NewsItem `json:"news"`
Reminders []BriefReminder `json:"reminders"`
Summary string `json:"summary"`
SummarySource string `json:"summary_source"` // "ai" | "fallback"
Status string `json:"status"` // pending, generated, delivered
GeneratedAt *time.Time `json:"generated_at,omitempty"`
DeliveredAt *time.Time `json:"delivered_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// WeatherData 天气数据
type WeatherData struct {
Location string `json:"location"`
Temp float64 `json:"temp"`
Condition string `json:"condition"`
Icon string `json:"icon"`
}
// NewsItem 新闻条目
type NewsItem struct {
Title string `json:"title"`
URL string `json:"url"`
Source string `json:"source"`
Summary string `json:"summary"`
}
// BriefReminder 简报中的提醒摘要
type BriefReminder struct {
ID string `json:"id"`
Title string `json:"title"`
RemindAt string `json:"remind_at"`
}
// BriefingStore 每日简报持久化存储
type BriefingStore struct {
db *sql.DB
}
// NewBriefingStore 使用已有数据库连接初始化简报存储并自动建表
func NewBriefingStore(db *sql.DB) (*BriefingStore, error) {
store := &BriefingStore{db: db}
if err := store.migrate(); err != nil {
return nil, fmt.Errorf("简报表迁移失败: %w", err)
}
logger.Println("[BriefingStore] 简报持久化存储已初始化")
return store, nil
}
// migrate 自动创建简报表结构
func (s *BriefingStore) migrate() error {
queries := []string{
`CREATE TABLE IF NOT EXISTS daily_briefings (
id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(255) NOT NULL,
date DATE NOT NULL,
weather JSONB DEFAULT '{}',
news JSONB DEFAULT '[]',
reminders JSONB DEFAULT '[]',
summary TEXT DEFAULT '',
summary_source VARCHAR(20) DEFAULT 'ai',
status VARCHAR(20) DEFAULT 'pending',
generated_at TIMESTAMPTZ,
delivered_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, date)
)`,
`ALTER TABLE daily_briefings ADD COLUMN IF NOT EXISTS summary_source VARCHAR(20) DEFAULT 'ai'`,
`CREATE INDEX IF NOT EXISTS idx_briefings_user_id ON daily_briefings(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_briefings_date ON daily_briefings(date)`,
`CREATE INDEX IF NOT EXISTS idx_briefings_user_date ON daily_briefings(user_id, date)`,
}
for _, q := range queries {
if _, err := s.db.Exec(q); err != nil {
return fmt.Errorf("迁移SQL执行失败: %w\nSQL: %s", err, q)
}
}
return nil
}
// CreateOrUpdateBriefing upsert 简报
func (s *BriefingStore) CreateOrUpdateBriefing(b *Briefing) error {
weatherJSON, err := json.Marshal(b.Weather)
if err != nil {
return fmt.Errorf("序列化天气数据失败: %w", err)
}
newsJSON, err := json.Marshal(b.News)
if err != nil {
return fmt.Errorf("序列化新闻数据失败: %w", err)
}
remindersJSON, err := json.Marshal(b.Reminders)
if err != nil {
return fmt.Errorf("序列化提醒数据失败: %w", err)
}
if b.SummarySource == "" {
b.SummarySource = "ai"
}
_, err = s.db.Exec(
`INSERT INTO daily_briefings (id, user_id, date, weather, news, reminders, summary, summary_source, status, generated_at, delivered_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
ON CONFLICT (user_id, date) DO UPDATE SET
weather = EXCLUDED.weather,
news = EXCLUDED.news,
reminders = EXCLUDED.reminders,
summary = EXCLUDED.summary,
summary_source = EXCLUDED.summary_source,
status = EXCLUDED.status,
generated_at = EXCLUDED.generated_at,
delivered_at = EXCLUDED.delivered_at`,
b.ID, b.UserID, b.Date, string(weatherJSON), string(newsJSON), string(remindersJSON),
b.Summary, b.SummarySource, b.Status, b.GeneratedAt, b.DeliveredAt,
)
if err != nil {
return fmt.Errorf("upsert 简报失败: %w", err)
}
return nil
}
// GetBriefingByDate 获取指定日期简报
func (s *BriefingStore) GetBriefingByDate(userID, date string) (*Briefing, error) {
row := s.db.QueryRow(
`SELECT id, user_id, date::TEXT, weather, news, reminders, summary, COALESCE(summary_source, 'ai'), status, generated_at, delivered_at, created_at
FROM daily_briefings WHERE user_id = $1 AND date = $2::DATE`,
userID, date,
)
b, err := s.scanBriefing(row)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("查询简报失败: %w", err)
}
return b, nil
}
// GetLatestBriefings 获取最近简报列表
func (s *BriefingStore) GetLatestBriefings(userID string, limit int) ([]Briefing, error) {
if limit <= 0 {
limit = 7
}
rows, err := s.db.Query(
`SELECT id, user_id, date::TEXT, weather, news, reminders, summary, COALESCE(summary_source, 'ai'), status, generated_at, delivered_at, created_at
FROM daily_briefings WHERE user_id = $1
ORDER BY date DESC LIMIT $2`,
userID, limit,
)
if err != nil {
return nil, fmt.Errorf("查询简报列表失败: %w", err)
}
defer rows.Close()
var briefings []Briefing
for rows.Next() {
var (
id, uid, date, summary, summarySource, status string
weatherRaw, newsRaw, remindersRaw []byte
generatedAt, deliveredAt, createdAt sql.NullTime
)
if err := rows.Scan(&id, &uid, &date, &weatherRaw, &newsRaw, &remindersRaw,
&summary, &summarySource, &status, &generatedAt, &deliveredAt, &createdAt); err != nil {
return nil, fmt.Errorf("扫描简报行失败: %w", err)
}
b := Briefing{
ID: id,
UserID: uid,
Date: date,
Summary: summary,
SummarySource: summarySource,
Status: status,
}
if weatherRaw != nil {
var w WeatherData
if err := json.Unmarshal(weatherRaw, &w); err == nil {
b.Weather = &w
}
}
if newsRaw != nil {
json.Unmarshal(newsRaw, &b.News)
}
if remindersRaw != nil {
json.Unmarshal(remindersRaw, &b.Reminders)
}
if generatedAt.Valid {
b.GeneratedAt = &generatedAt.Time
}
if deliveredAt.Valid {
b.DeliveredAt = &deliveredAt.Time
}
b.CreatedAt = createdAt.Time
// 确保切片不为 nil
if b.News == nil {
b.News = []NewsItem{}
}
if b.Reminders == nil {
b.Reminders = []BriefReminder{}
}
if b.Weather == nil {
b.Weather = &WeatherData{}
}
briefings = append(briefings, b)
}
if briefings == nil {
briefings = []Briefing{}
}
return briefings, rows.Err()
}
// GetUsersWithBriefings 获取拥有简报的所有用户 ID 列表(用于调度器)
func (s *BriefingStore) GetUsersWithBriefings() ([]string, error) {
rows, err := s.db.Query(`SELECT DISTINCT user_id FROM daily_briefings`)
if err != nil {
return nil, fmt.Errorf("查询简报用户列表失败: %w", err)
}
defer rows.Close()
var userIDs []string
for rows.Next() {
var uid string
if err := rows.Scan(&uid); err != nil {
return nil, fmt.Errorf("扫描用户ID失败: %w", err)
}
userIDs = append(userIDs, uid)
}
if userIDs == nil {
userIDs = []string{}
}
return userIDs, rows.Err()
}
// GetAllUsers 获取所有用户 ID(从 reminders 表获取,作为降级方案)
func (s *BriefingStore) GetAllUsers() ([]string, error) {
rows, err := s.db.Query(`SELECT DISTINCT user_id FROM reminders`)
if err != nil {
return nil, fmt.Errorf("查询用户列表失败: %w", err)
}
defer rows.Close()
var userIDs []string
for rows.Next() {
var uid string
if err := rows.Scan(&uid); err != nil {
return nil, fmt.Errorf("扫描用户ID失败: %w", err)
}
userIDs = append(userIDs, uid)
}
if userIDs == nil {
userIDs = []string{}
}
return userIDs, rows.Err()
}
// scanBriefing 扫描单行简报
func (s *BriefingStore) scanBriefing(row *sql.Row) (*Briefing, error) {
var (
id, uid, date, summary, summarySource, status string
weatherRaw, newsRaw, remindersRaw []byte
generatedAt, deliveredAt, createdAt sql.NullTime
)
if err := row.Scan(&id, &uid, &date, &weatherRaw, &newsRaw, &remindersRaw,
&summary, &summarySource, &status, &generatedAt, &deliveredAt, &createdAt); err != nil {
return nil, err
}
b := &Briefing{
ID: id,
UserID: uid,
Date: date,
Summary: summary,
SummarySource: summarySource,
Status: status,
}
if weatherRaw != nil {
var w WeatherData
if err := json.Unmarshal(weatherRaw, &w); err == nil {
b.Weather = &w
}
}
if b.Weather == nil {
b.Weather = &WeatherData{}
}
if newsRaw != nil {
json.Unmarshal(newsRaw, &b.News)
}
if b.News == nil {
b.News = []NewsItem{}
}
if remindersRaw != nil {
json.Unmarshal(remindersRaw, &b.Reminders)
}
if b.Reminders == nil {
b.Reminders = []BriefReminder{}
}
if generatedAt.Valid {
b.GeneratedAt = &generatedAt.Time
}
if deliveredAt.Valid {
b.DeliveredAt = &deliveredAt.Time
}
b.CreatedAt = createdAt.Time
return b, nil
}
-1
View File
@@ -100,7 +100,6 @@ export const SERVICES = {
JWT_SECRET: process.env.JWT_SECRET || 'dev-secret-key-change-me',
AI_CORE_URL: 'http://localhost:8081',
MEMORY_SERVICE_URL: process.env.MEMORY_SERVICE_URL || 'http://localhost:8091',
TOOL_ENGINE_URL: process.env.TOOL_ENGINE_URL || 'http://localhost:8092',
ADMIN_USERNAME: process.env.ADMIN_USERNAME || 'admin',
ADMIN_PASSWORD: process.env.ADMIN_PASSWORD || 'cyrene-dev-admin',
REGISTRATION_ENABLED: process.env.REGISTRATION_ENABLED || 'true',