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:
@@ -1,8 +1,11 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -305,6 +308,270 @@ func (h *SessionHandler) GetSession(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, session)
|
||||
}
|
||||
|
||||
// ========== GET /api/v1/messages/search?q=xxx&user_id=xxx&limit=50&offset=0 — 全文搜索消息 ==========
|
||||
|
||||
// SearchMessages 全文搜索消息
|
||||
func (h *SessionHandler) SearchMessages(c *gin.Context) {
|
||||
query := c.Query("q")
|
||||
if query == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少搜索关键词参数 q", "errorType": "missing_query"})
|
||||
return
|
||||
}
|
||||
|
||||
userID := c.Query("user_id")
|
||||
if userID == "" {
|
||||
userID = middleware.GetUserID(c)
|
||||
}
|
||||
|
||||
limit := 50
|
||||
offset := 0
|
||||
if l := c.Query("limit"); l != "" {
|
||||
parsed := 0
|
||||
for _, ch := range l {
|
||||
if ch < '0' || ch > '9' {
|
||||
break
|
||||
}
|
||||
parsed = parsed*10 + int(ch-'0')
|
||||
}
|
||||
if parsed > 0 && parsed <= 200 {
|
||||
limit = parsed
|
||||
}
|
||||
}
|
||||
if o := c.Query("offset"); o != "" {
|
||||
parsed := 0
|
||||
for _, ch := range o {
|
||||
if ch < '0' || ch > '9' {
|
||||
break
|
||||
}
|
||||
parsed = parsed*10 + int(ch-'0')
|
||||
}
|
||||
if parsed >= 0 {
|
||||
offset = parsed
|
||||
}
|
||||
}
|
||||
|
||||
if !h.useDB {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"results": []gin.H{},
|
||||
"total": 0,
|
||||
"query": query,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
results, total, err := h.store.SearchMessages(userID, query, limit, offset)
|
||||
if err != nil {
|
||||
log.Printf("[SessionHandler] 搜索消息失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "搜索失败", "errorType": "db_error"})
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为统一格式
|
||||
items := make([]gin.H, 0, len(results))
|
||||
for _, r := range results {
|
||||
items = append(items, gin.H{
|
||||
"message_id": r.MessageID,
|
||||
"session_id": r.SessionID,
|
||||
"session_title": r.SessionTitle,
|
||||
"role": r.Role,
|
||||
"content": r.Content,
|
||||
"created_at": r.CreatedAt.UnixMilli(),
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"results": items,
|
||||
"total": total,
|
||||
"query": query,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
})
|
||||
}
|
||||
|
||||
// ========== GET /api/v1/sessions/:id/export?format=json|markdown|txt — 导出会话 ==========
|
||||
|
||||
// ExportSession 导出会话为指定格式
|
||||
func (h *SessionHandler) ExportSession(c *gin.Context) {
|
||||
sessionID := c.Param("id")
|
||||
format := c.Query("format")
|
||||
if format == "" {
|
||||
format = "json"
|
||||
}
|
||||
|
||||
// 验证格式
|
||||
switch format {
|
||||
case "json", "markdown", "txt":
|
||||
// valid
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "不支持的导出格式",
|
||||
"errorType": "invalid_format",
|
||||
"hint": "支持的格式: json, markdown, txt",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if !h.useDB {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"error": "会话存储不可用",
|
||||
"errorType": "store_unavailable",
|
||||
"hint": "数据库连接未建立,无法导出会话",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 获取会话信息
|
||||
session, err := h.store.GetSession(sessionID)
|
||||
if err != nil {
|
||||
log.Printf("[SessionHandler] 查询会话失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询会话失败", "errorType": "db_error"})
|
||||
return
|
||||
}
|
||||
if session == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "会话不存在",
|
||||
"errorType": "session_not_found",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 获取所有消息 (不限制数量,导出全部)
|
||||
messages, err := h.store.GetMessages(sessionID, 0)
|
||||
if err != nil {
|
||||
log.Printf("[SessionHandler] 查询消息失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询消息失败", "errorType": "db_error"})
|
||||
return
|
||||
}
|
||||
if messages == nil {
|
||||
messages = []store.Message{}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
switch format {
|
||||
case "json":
|
||||
h.exportJSON(c, session, messages, now)
|
||||
case "markdown":
|
||||
h.exportMarkdown(c, session, messages, now)
|
||||
case "txt":
|
||||
h.exportTXT(c, session, messages, now)
|
||||
}
|
||||
}
|
||||
|
||||
// exportJSON 导出 JSON 格式
|
||||
func (h *SessionHandler) exportJSON(c *gin.Context, session *store.Session, messages []store.Message, now time.Time) {
|
||||
type msgOut struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
type sessionOut struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
type export struct {
|
||||
Session sessionOut `json:"session"`
|
||||
Messages []msgOut `json:"messages"`
|
||||
}
|
||||
|
||||
msgs := make([]msgOut, 0, len(messages))
|
||||
for _, m := range messages {
|
||||
msgs = append(msgs, msgOut{
|
||||
Role: m.Role,
|
||||
Content: m.Content,
|
||||
CreatedAt: m.CreatedAt.UnixMilli(),
|
||||
})
|
||||
}
|
||||
|
||||
data := export{
|
||||
Session: sessionOut{
|
||||
ID: session.ID,
|
||||
Title: session.Title,
|
||||
CreatedAt: session.CreatedAt.UnixMilli(),
|
||||
UpdatedAt: session.UpdatedAt.UnixMilli(),
|
||||
},
|
||||
Messages: msgs,
|
||||
}
|
||||
|
||||
jsonBytes, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
log.Printf("[SessionHandler] JSON序列化失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "导出失败", "errorType": "serialization_error"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "application/json; charset=utf-8")
|
||||
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="session_%s.json"`, session.ID))
|
||||
c.Data(http.StatusOK, "application/json; charset=utf-8", jsonBytes)
|
||||
}
|
||||
|
||||
// exportMarkdown 导出 Markdown 格式
|
||||
func (h *SessionHandler) exportMarkdown(c *gin.Context, session *store.Session, messages []store.Message, now time.Time) {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("# 对话导出: %s\n", session.Title))
|
||||
sb.WriteString(fmt.Sprintf("**会话 ID**: %s\n", session.ID))
|
||||
sb.WriteString(fmt.Sprintf("**导出时间**: %s\n", now.Format("2006-01-02 15:04:05")))
|
||||
sb.WriteString(fmt.Sprintf("**消息数量**: %d\n", len(messages)))
|
||||
sb.WriteString("\n---\n\n")
|
||||
|
||||
for _, m := range messages {
|
||||
timeStr := m.CreatedAt.Format("2006-01-02 15:04:05")
|
||||
switch m.Role {
|
||||
case "user":
|
||||
sb.WriteString(fmt.Sprintf("### 👤 用户 (%s)\n\n", timeStr))
|
||||
case "assistant":
|
||||
sb.WriteString(fmt.Sprintf("### 🤖 昔涟 (%s)\n\n", timeStr))
|
||||
case "system":
|
||||
sb.WriteString(fmt.Sprintf("### ⚙️ 系统 (%s)\n\n", timeStr))
|
||||
default:
|
||||
sb.WriteString(fmt.Sprintf("### %s (%s)\n\n", m.Role, timeStr))
|
||||
}
|
||||
sb.WriteString(m.Content)
|
||||
sb.WriteString("\n\n---\n\n")
|
||||
}
|
||||
|
||||
content := sb.String()
|
||||
c.Header("Content-Type", "text/markdown; charset=utf-8")
|
||||
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="session_%s.md"`, session.ID))
|
||||
c.Data(http.StatusOK, "text/markdown; charset=utf-8", []byte(content))
|
||||
}
|
||||
|
||||
// exportTXT 导出纯文本格式
|
||||
func (h *SessionHandler) exportTXT(c *gin.Context, session *store.Session, messages []store.Message, now time.Time) {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("对话导出: %s\n", session.Title))
|
||||
sb.WriteString(fmt.Sprintf("会话 ID: %s\n", session.ID))
|
||||
sb.WriteString(fmt.Sprintf("导出时间: %s\n", now.Format("2006-01-02 15:04:05")))
|
||||
sb.WriteString(fmt.Sprintf("消息数量: %d\n", len(messages)))
|
||||
sb.WriteString(strings.Repeat("=", 50))
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
for _, m := range messages {
|
||||
timeStr := m.CreatedAt.Format("2006-01-02 15:04:05")
|
||||
roleLabel := m.Role
|
||||
switch m.Role {
|
||||
case "user":
|
||||
roleLabel = "用户"
|
||||
case "assistant":
|
||||
roleLabel = "昔涟"
|
||||
case "system":
|
||||
roleLabel = "系统"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("[%s] %s:\n%s\n\n", timeStr, roleLabel, m.Content))
|
||||
}
|
||||
|
||||
content := sb.String()
|
||||
c.Header("Content-Type", "text/plain; charset=utf-8")
|
||||
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="session_%s.txt"`, session.ID))
|
||||
c.Data(http.StatusOK, "text/plain; charset=utf-8", []byte(content))
|
||||
}
|
||||
|
||||
// 简单的工具函数
|
||||
func randomID(n int) string {
|
||||
const letters = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
|
||||
Reference in New Issue
Block a user