From cc195bca96a5d5c20c8c7a4c243fe47141f7d09c Mon Sep 17 00:00:00 2001
From: Symb0x76 <9667434@qq.com>
Date: Fri, 5 Sep 2025 11:55:14 +0800
Subject: [PATCH] feat/diary
---
internal/chatlog/http/route.go | 478 ++++++++++++++-----------
internal/chatlog/http/static/index.htm | 37 ++
internal/model/message.go | 11 +
3 files changed, 317 insertions(+), 209 deletions(-)
diff --git a/internal/chatlog/http/route.go b/internal/chatlog/http/route.go
index 88ee7a0..04a08c0 100644
--- a/internal/chatlog/http/route.go
+++ b/internal/chatlog/http/route.go
@@ -11,6 +11,7 @@ import (
"path/filepath"
"regexp"
"strings"
+ "time"
"github.com/gin-gonic/gin"
@@ -21,11 +22,15 @@ import (
"github.com/sjzar/chatlog/pkg/util/silk"
)
-// EFS holds embedded file system data for static assets.
-//
//go:embed static
var EFS embed.FS
+// 统一的 HTML 预览组件片段
+var previewHTMLSnippet = `
+
+
+`
+
func (s *Service) initRouter() {
s.initBaseRouter()
s.initMediaRouter()
@@ -35,16 +40,19 @@ func (s *Service) initRouter() {
func (s *Service) initBaseRouter() {
staticDir, _ := fs.Sub(EFS, "static")
-
s.router.StaticFS("/static", http.FS(staticDir))
s.router.StaticFileFS("/favicon.ico", "./favicon.ico", http.FS(staticDir))
s.router.StaticFileFS("/", "./index.htm", http.FS(staticDir))
-
- s.router.GET("/health", func(ctx *gin.Context) {
- ctx.JSON(http.StatusOK, gin.H{"status": "ok"})
+ s.router.GET("/health", func(ctx *gin.Context) { ctx.JSON(http.StatusOK, gin.H{"status":"ok"}) })
+ s.router.NoRoute(func(c *gin.Context){
+ path := c.Request.URL.Path
+ if strings.HasPrefix(path,"/api") || strings.HasPrefix(path,"/static") {
+ c.JSON(http.StatusNotFound, gin.H{"error":"Not found"})
+ return
+ }
+ c.Header("Cache-Control","no-cache, no-store, max-age=0, must-revalidate, value")
+ c.Redirect(http.StatusFound, "/")
})
-
- s.router.NoRoute(s.NoRoute)
}
func (s *Service) initMediaRouter() {
@@ -62,216 +70,142 @@ func (s *Service) initAPIRouter() {
api.GET("/contact", s.handleContacts)
api.GET("/chatroom", s.handleChatRooms)
api.GET("/session", s.handleSessions)
+ api.GET("/diary", s.handleDiary)
}
}
func (s *Service) initMCPRouter() {
- s.router.Any("/mcp", func(c *gin.Context) {
- s.mcpStreamableServer.ServeHTTP(c.Writer, c.Request)
- })
- s.router.Any("/sse", func(c *gin.Context) {
- s.mcpSSEServer.ServeHTTP(c.Writer, c.Request)
- })
- s.router.Any("/message", func(c *gin.Context) {
- s.mcpSSEServer.ServeHTTP(c.Writer, c.Request)
- })
-}
-
-// NoRoute handles 404 Not Found errors. If the request URL starts with "/api"
-// or "/static", it responds with a JSON error. Otherwise, it redirects to the root path.
-func (s *Service) NoRoute(c *gin.Context) {
- path := c.Request.URL.Path
- switch {
- case strings.HasPrefix(path, "/api"), strings.HasPrefix(path, "/static"):
- c.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
- default:
- c.Header("Cache-Control", "no-cache, no-store, max-age=0, must-revalidate, value")
- c.Redirect(http.StatusFound, "/")
- }
+ s.router.Any("/mcp", func(c *gin.Context) { s.mcpStreamableServer.ServeHTTP(c.Writer, c.Request) })
+ s.router.Any("/sse", func(c *gin.Context) { s.mcpSSEServer.ServeHTTP(c.Writer, c.Request) })
+ s.router.Any("/message", func(c *gin.Context) { s.mcpSSEServer.ServeHTTP(c.Writer, c.Request) })
}
func (s *Service) handleChatlog(c *gin.Context) {
+ q := struct {
+ Time string `form:"time"`
+ Talker string `form:"talker"`
+ Sender string `form:"sender"`
+ Keyword string `form:"keyword"`
+ Limit int `form:"limit"`
+ Offset int `form:"offset"`
+ Format string `form:"format"`
+ }{}
- q := struct {
- Time string `form:"time"`
- Talker string `form:"talker"`
- Sender string `form:"sender"`
- Keyword string `form:"keyword"`
- Limit int `form:"limit"`
- Offset int `form:"offset"`
- Format string `form:"format"`
- }{}
+ if err := c.BindQuery(&q); err != nil { errors.Err(c, err); return }
- if err := c.BindQuery(&q); err != nil {
- errors.Err(c, err)
- return
- }
+ start, end, ok := util.TimeRangeOf(q.Time)
+ if !ok { errors.Err(c, errors.InvalidArg("time")) }
+ if q.Limit < 0 { q.Limit = 0 }
+ if q.Offset < 0 { q.Offset = 0 }
- var err error
- start, end, ok := util.TimeRangeOf(q.Time)
- if !ok {
- errors.Err(c, errors.InvalidArg("time"))
- }
- if q.Limit < 0 {
- q.Limit = 0
- }
-
- if q.Offset < 0 {
- q.Offset = 0
- }
-
- // 当未提供 talker 时,返回所有会话内消息并按 chatroom(talker) 分组
- if q.Talker == "" {
- // 获取所有会话(不分页)
- sessionsResp, err := s.db.GetSessions("", 0, 0)
- if err != nil {
- errors.Err(c, err)
+ // 1. 未指定 talker: 分组输出
+ if q.Talker == "" {
+ sessionsResp, err := s.db.GetSessions("", 0, 0)
+ if err != nil { errors.Err(c, err); return }
+ type grouped struct {
+ Talker string `json:"talker"`
+ TalkerName string `json:"talkerName,omitempty"`
+ Messages []*model.Message `json:"messages"`
+ }
+ groups := make([]*grouped,0)
+ for _, sess := range sessionsResp.Items {
+ msgs, err := s.db.GetMessages(start, end, sess.UserName, q.Sender, q.Keyword, 0, 0)
+ if err != nil || len(msgs)==0 { continue }
+ groups = append(groups, &grouped{Talker:sess.UserName, TalkerName:sess.NickName, Messages:msgs})
+ }
+ switch strings.ToLower(q.Format) {
+ case "html":
+ c.Writer.Header().Set("Content-Type", "text/html; charset=utf-8")
+ c.Writer.WriteString("Chatlog")
+ c.Writer.WriteString(fmt.Sprintf("All Messages %s ~ %s
", start.Format("2006-01-02 15:04:05"), end.Format("2006-01-02 15:04:05")))
+ for _, g := range groups {
+ title := g.Talker
+ if g.TalkerName != "" { title = fmt.Sprintf("%s (%s)", g.TalkerName, g.Talker) }
+ c.Writer.WriteString("" + template.HTMLEscapeString(title) + fmt.Sprintf(" - %d 条消息
", len(g.Messages)))
+ for _, m := range g.Messages {
+ m.SetContent("host", c.Request.Host)
+ senderDisplay := m.Sender
+ if m.IsSelf { senderDisplay = "我" }
+ if m.SenderName != "" { senderDisplay = template.HTMLEscapeString(m.SenderName) + "(" + template.HTMLEscapeString(senderDisplay) + ")" } else { senderDisplay = template.HTMLEscapeString(senderDisplay) }
+ c.Writer.WriteString(""+ senderDisplay +""+ m.Time.Format("2006-01-02 15:04:05") +"
"+ messageHTMLPlaceholder(m) +"
")
+ }
+ c.Writer.WriteString(" ")
+ }
+ c.Writer.WriteString(previewHTMLSnippet)
+ c.Writer.WriteString("")
+ case "json":
+ c.JSON(http.StatusOK, groups)
+ case "csv":
+ c.Writer.Header().Set("Content-Type", "text/csv; charset=utf-8")
+ c.Writer.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=all_%s_%s.csv", start.Format("2006-01-02"), end.Format("2006-01-02")))
+ c.Writer.Header().Set("Cache-Control", "no-cache")
+ c.Writer.Header().Set("Connection", "keep-alive")
+ c.Writer.Flush()
+ csvWriter := csv.NewWriter(c.Writer)
+ csvWriter.Write([]string{"Talker","TalkerName","Time","SenderName","Sender","Content"})
+ for _, g := range groups { for _, m := range g.Messages { csvWriter.Write([]string{g.Talker, g.TalkerName, m.Time.Format("2006-01-02 15:04:05"), m.SenderName, m.Sender, m.PlainTextContent()}) } }
+ csvWriter.Flush()
+ default:
+ c.Writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ c.Writer.Header().Set("Cache-Control", "no-cache")
+ c.Writer.Header().Set("Connection", "keep-alive")
+ c.Writer.Flush()
+ for _, g := range groups {
+ header := g.Talker
+ if g.TalkerName!="" { header = fmt.Sprintf("%s (%s)", g.TalkerName, g.Talker) }
+ c.Writer.WriteString(header+"\n")
+ for _, m := range g.Messages {
+ sender := m.Sender
+ if m.IsSelf { sender = "我" }
+ if m.SenderName!="" { sender = m.SenderName + "("+sender+")" }
+ c.Writer.WriteString(m.Time.Format("2006-01-02 15:04:05")+" "+sender+" "+m.PlainTextContent()+"\n")
+ }
+ c.Writer.WriteString("-----------------------------\n")
+ }
+ }
return
}
- // 结果分组结构
- type grouped struct {
- Talker string `json:"talker"`
- TalkerName string `json:"talkerName,omitempty"`
- Messages []*model.Message `json:"messages"`
- }
-
- groups := make([]*grouped, 0)
-
- // 遍历每个会话获取消息
- for _, sess := range sessionsResp.Items {
- msgs, err := s.db.GetMessages(start, end, sess.UserName, q.Sender, q.Keyword, 0, 0)
- if err != nil || len(msgs) == 0 {
- continue
- }
- g := &grouped{Talker: sess.UserName, TalkerName: sess.NickName, Messages: msgs}
- groups = append(groups, g)
- }
-
+ // 2. 指定 talker: 单会话消息
+ messages, err := s.db.GetMessages(start, end, q.Talker, q.Sender, q.Keyword, q.Limit, q.Offset)
+ if err != nil { errors.Err(c, err); return }
switch strings.ToLower(q.Format) {
case "html":
- // HTML 输出:分组折叠
c.Writer.Header().Set("Content-Type", "text/html; charset=utf-8")
- c.Writer.WriteString("Chatlog")
- c.Writer.WriteString(fmt.Sprintf("All Messages %s ~ %s
", start.Format("2006-01-02 15:04:05"), end.Format("2006-01-02 15:04:05")))
- for _, g := range groups {
- title := g.Talker
- if g.TalkerName != "" {
- title = fmt.Sprintf("%s (%s)", g.TalkerName, g.Talker)
- }
- c.Writer.WriteString("" + template.HTMLEscapeString(title) + fmt.Sprintf(" - %d 条消息
", len(g.Messages)))
- for _, m := range g.Messages {
- m.SetContent("host", c.Request.Host)
- c.Writer.WriteString("")
- c.Writer.WriteString("
")
- c.Writer.WriteString(template.HTMLEscapeString(m.SenderName))
- if m.SenderName != "" { c.Writer.WriteString("(") }
- c.Writer.WriteString(template.HTMLEscapeString(m.Sender))
- if m.SenderName != "" { c.Writer.WriteString(")") }
- c.Writer.WriteString(" ")
- c.Writer.WriteString(m.Time.Format("2006-01-02 15:04:05"))
- c.Writer.WriteString("
")
- c.Writer.WriteString("
")
- c.Writer.WriteString(renderMarkdownImages(m.PlainTextContent()))
- c.Writer.WriteString(" ")
- }
- c.Writer.WriteString(" ")
+ c.Writer.WriteString("Chatlog")
+ c.Writer.WriteString(fmt.Sprintf("Messages %s ~ %s (%s)
", start.Format("2006-01-02 15:04:05"), end.Format("2006-01-02 15:04:05"), template.HTMLEscapeString(q.Talker)))
+ for _, m := range messages {
+ m.SetContent("host", c.Request.Host)
+ c.Writer.WriteString("")
+ if m.SenderName != "" { c.Writer.WriteString(template.HTMLEscapeString(m.SenderName)+"(") }
+ c.Writer.WriteString(template.HTMLEscapeString(m.Sender))
+ if m.SenderName != "" { c.Writer.WriteString(")") }
+ c.Writer.WriteString(""+m.Time.Format("2006-01-02 15:04:05")+"
")
+ c.Writer.WriteString(messageHTMLPlaceholder(m))
+ c.Writer.WriteString(" ")
}
+ c.Writer.WriteString(previewHTMLSnippet)
c.Writer.WriteString("")
- case "json":
- c.JSON(http.StatusOK, groups)
case "csv":
c.Writer.Header().Set("Content-Type", "text/csv; charset=utf-8")
- c.Writer.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=all_%s_%s.csv", start.Format("2006-01-02"), end.Format("2006-01-02")))
+ c.Writer.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s_%s_%s.csv", q.Talker, start.Format("2006-01-02"), end.Format("2006-01-02")))
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
c.Writer.Flush()
csvWriter := csv.NewWriter(c.Writer)
- csvWriter.Write([]string{"Talker", "TalkerName", "Time", "SenderName", "Sender", "Content"})
- for _, g := range groups {
- for _, m := range g.Messages {
- row := m.CSV(c.Request.Host)
- // 原 CSV: Time, SenderName, Sender, TalkerName, Talker, Content
- csvWriter.Write([]string{m.Talker, m.TalkerName, row[0], row[1], row[2], row[5]})
- }
- }
+ csvWriter.Write([]string{"Time","SenderName","Sender","TalkerName","Talker","Content"})
+ for _, m := range messages { csvWriter.Write(m.CSV(c.Request.Host)) }
csvWriter.Flush()
+ case "json":
+ c.JSON(http.StatusOK, messages)
default:
c.Writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
c.Writer.Flush()
- for _, g := range groups {
- c.Writer.WriteString("====================\n")
- if g.TalkerName != "" {
- c.Writer.WriteString(fmt.Sprintf("%s (%s)\n", g.TalkerName, g.Talker))
- } else {
- c.Writer.WriteString(fmt.Sprintf("%s\n", g.Talker))
- }
- c.Writer.WriteString("--------------------\n")
- for _, m := range g.Messages {
- c.Writer.WriteString(m.PlainText(true, util.PerfectTimeFormat(start, end), c.Request.Host))
- c.Writer.WriteString("\n")
- }
- c.Writer.Flush()
+ for _, m := range messages {
+ c.Writer.WriteString(m.PlainText(strings.Contains(q.Talker, ","), util.PerfectTimeFormat(start, end), c.Request.Host)+"\n")
}
- }
- return
- }
-
- // 正常:指定 talker
- messages, err := s.db.GetMessages(start, end, q.Talker, q.Sender, q.Keyword, q.Limit, q.Offset)
- if err != nil {
- errors.Err(c, err)
- return
- }
-
- switch strings.ToLower(q.Format) {
-case "html":
- c.Writer.Header().Set("Content-Type", "text/html; charset=utf-8")
- c.Writer.WriteString("Chatlog")
- c.Writer.WriteString(fmt.Sprintf("Messages %s ~ %s (%s)
", start.Format("2006-01-02 15:04:05"), end.Format("2006-01-02 15:04:05"), template.HTMLEscapeString(q.Talker)))
- for _, m := range messages {
- m.SetContent("host", c.Request.Host)
- c.Writer.WriteString("")
- c.Writer.WriteString("
")
- c.Writer.WriteString(template.HTMLEscapeString(m.SenderName))
- if m.SenderName != "" { c.Writer.WriteString("(") }
- c.Writer.WriteString(template.HTMLEscapeString(m.Sender))
- if m.SenderName != "" { c.Writer.WriteString(")") }
- c.Writer.WriteString("")
- c.Writer.WriteString(m.Time.Format("2006-01-02 15:04:05"))
- c.Writer.WriteString("
")
- c.Writer.WriteString("
")
- c.Writer.WriteString(renderMarkdownImages(m.PlainTextContent()))
- c.Writer.WriteString(" ")
- }
- c.Writer.WriteString("")
-case "csv":
- c.Writer.Header().Set("Content-Type", "text/csv; charset=utf-8")
- c.Writer.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s_%s_%s.csv", q.Talker, start.Format("2006-01-02"), end.Format("2006-01-02")))
- c.Writer.Header().Set("Cache-Control", "no-cache")
- c.Writer.Header().Set("Connection", "keep-alive")
- c.Writer.Flush()
-
- csvWriter := csv.NewWriter(c.Writer)
- csvWriter.Write([]string{"Time", "SenderName", "Sender", "TalkerName", "Talker", "Content"})
- for _, m := range messages {
- csvWriter.Write(m.CSV(c.Request.Host))
- }
- csvWriter.Flush()
- case "json":
- c.JSON(http.StatusOK, messages)
- default:
- c.Writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
- c.Writer.Header().Set("Cache-Control", "no-cache")
- c.Writer.Header().Set("Connection", "keep-alive")
- c.Writer.Flush()
- for _, m := range messages {
- c.Writer.WriteString(m.PlainText(strings.Contains(q.Talker, ","), util.PerfectTimeFormat(start, end), c.Request.Host))
- c.Writer.WriteString("\n")
- c.Writer.Flush()
- }
}
}
@@ -385,6 +319,33 @@ func (s *Service) handleSessions(c *gin.Context) {
}
format := strings.ToLower(q.Format)
switch format {
+ case "html":
+ c.Writer.Header().Set("Content-Type", "text/html; charset=utf-8")
+ c.Writer.WriteHeader(http.StatusOK)
+ c.Writer.Write([]byte(``))
+ for _, session := range sessions.Items {
+ // 转义
+ name := template.HTMLEscapeString(session.NickName)
+ uname := template.HTMLEscapeString(session.UserName)
+ content := template.HTMLEscapeString(session.Content)
+ if len(content) > 400 { // 简单截断,避免过长
+ content = content[:400] + "..."
+ }
+ content = strings.ReplaceAll(content, "\r", "")
+ content = strings.ReplaceAll(content, "\n", "\n") // 让 pre-wrap 生效
+ c.Writer.Write([]byte(`
` + name + `(` + uname + `)` + session.NTime.Format("2006-01-02 15:04:05") + `
` + content + `
`))
+ }
+ c.Writer.Write([]byte(`
`))
+ c.Writer.Write([]byte(previewHTMLSnippet))
+ c.Writer.Flush()
+ return
case "csv":
c.Writer.Header().Set("Content-Type", "text/csv; charset=utf-8")
c.Writer.Header().Set("Cache-Control", "no-cache")
@@ -412,6 +373,95 @@ func (s *Service) handleSessions(c *gin.Context) {
}
}
+// handleDiary 返回最近24小时内“我”发送的消息,按 talker 分组。
+// GET /api/v1/diary?format=(html|json|csv|text)
+func (s *Service) handleDiary(c *gin.Context) {
+ q := struct { Format string `form:"format"` }{}
+ if err := c.BindQuery(&q); err != nil { errors.Err(c, err); return }
+ end := time.Now()
+ start := end.Add(-24 * time.Hour)
+
+ // 获取所有会话
+ sessionsResp, err := s.db.GetSessions("", 0, 0)
+ if err != nil { errors.Err(c, err); return }
+
+ type grouped struct {
+ Talker string `json:"talker"`
+ TalkerName string `json:"talkerName,omitempty"`
+ Messages []*model.Message `json:"messages"`
+ }
+ groups := make([]*grouped,0)
+
+ for _, sess := range sessionsResp.Items {
+ msgs, err := s.db.GetMessages(start, end, sess.UserName, "", "", 0, 0)
+ if err != nil || len(msgs)==0 { continue }
+ hasSelf := false
+ for _, m := range msgs { if m.IsSelf { hasSelf = true; break } }
+ if !hasSelf { continue }
+ groups = append(groups, &grouped{Talker:sess.UserName, TalkerName:sess.NickName, Messages:msgs})
+ }
+
+ format := strings.ToLower(q.Format)
+ switch format {
+ case "html":
+ c.Writer.Header().Set("Content-Type", "text/html; charset=utf-8")
+ c.Writer.WriteString(`Diary - 24h`)
+ c.Writer.WriteString(fmt.Sprintf("最近24小时我参与过的会话全部消息(%s ~ %s)
", start.Format("2006-01-02 15:04:05"), end.Format("2006-01-02 15:04:05")))
+ for _, g := range groups {
+ title := g.Talker
+ if g.TalkerName != "" { title = fmt.Sprintf("%s (%s)", g.TalkerName, g.Talker) }
+ c.Writer.WriteString("" + template.HTMLEscapeString(title) + fmt.Sprintf(" - %d 条消息
", len(g.Messages)))
+ for _, m := range g.Messages {
+ m.SetContent("host", c.Request.Host)
+ senderDisplay := m.Sender
+ if m.IsSelf { senderDisplay = "我" }
+ if m.SenderName != "" {
+ senderDisplay = template.HTMLEscapeString(m.SenderName) + "(" + template.HTMLEscapeString(senderDisplay) + ")"
+ } else {
+ senderDisplay = template.HTMLEscapeString(senderDisplay)
+ }
+ c.Writer.WriteString("" + senderDisplay + "" + m.Time.Format("2006-01-02 15:04:05") + "
" + messageHTMLPlaceholder(m) + "
")
+ }
+ c.Writer.WriteString(" ")
+ }
+ c.Writer.WriteString(previewHTMLSnippet)
+ c.Writer.WriteString("")
+ case "json":
+ c.JSON(http.StatusOK, groups)
+ case "csv":
+ c.Writer.Header().Set("Content-Type", "text/csv; charset=utf-8")
+ c.Writer.Header().Set("Cache-Control", "no-cache")
+ c.Writer.Header().Set("Connection", "keep-alive")
+ c.Writer.Flush()
+ writer := csv.NewWriter(c.Writer)
+ writer.Write([]string{"Talker","TalkerName","Time","SenderName","Sender","Content"})
+ for _, g := range groups { for _, m := range g.Messages { writer.Write([]string{m.Talker, m.TalkerName, m.Time.Format("2006-01-02 15:04:05"), m.SenderName, m.Sender, m.PlainTextContent()}) } }
+ writer.Flush()
+ default:
+ c.Writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ c.Writer.Header().Set("Cache-Control", "no-cache")
+ c.Writer.Header().Set("Connection", "keep-alive")
+ c.Writer.Flush()
+ for _, g := range groups {
+ if g.TalkerName!="" { c.Writer.WriteString(fmt.Sprintf("%s (%s)\n", g.TalkerName, g.Talker)) } else { c.Writer.WriteString(g.Talker+"\n") }
+ for _, m := range g.Messages {
+ senderDisplay := m.Sender
+ if m.IsSelf { senderDisplay = "我" }
+ if m.SenderName != "" {
+ senderDisplay = m.SenderName + "(" + senderDisplay + ")"
+ }
+ c.Writer.WriteString(m.Time.Format("2006-01-02 15:04:05"))
+ c.Writer.WriteString(" ")
+ c.Writer.WriteString(senderDisplay)
+ c.Writer.WriteString(" ")
+ c.Writer.WriteString(m.PlainTextContent())
+ c.Writer.WriteString("\n")
+ }
+ c.Writer.WriteString("-----------------------------\n")
+ }
+ }
+}
+
func (s *Service) handleMedia(c *gin.Context, _type string) {
key := strings.TrimPrefix(c.Param("key"), "/")
if key == "" {
@@ -542,27 +592,37 @@ func (s *Service) HandleVoice(c *gin.Context, data []byte) {
c.Data(http.StatusOK, "audio/mp3", out)
}
-// Markdown 图片语法转换为
,其余文本保持转义
-var mdImgPattern = regexp.MustCompile(`!\[([^\]]*)\]\(([^)]+)\)`)
+// 统一占位符:将 PlainTextContent 里形如  或 [标签](url) 的模式转成超链接形式,仅显示 [标签]。
+var placeholderPattern = regexp.MustCompile(`!?\[([^\]]+)\]\((https?://[^)]+)\)`)
-func renderMarkdownImages(src string) string {
- if src == "" {
- return ""
- }
- matches := mdImgPattern.FindAllStringSubmatchIndex(src, -1)
- if len(matches) == 0 {
- return template.HTMLEscapeString(src)
- }
- var b strings.Builder
- last := 0
- for _, m := range matches {
- // m: full, alt, url indices
- b.WriteString(template.HTMLEscapeString(src[last:m[0]]))
- alt := template.HTMLEscapeString(src[m[2]:m[3]])
- url := template.HTMLEscapeString(src[m[4]:m[5]])
- b.WriteString(`
`)
- last = m[1]
- }
- b.WriteString(template.HTMLEscapeString(src[last:]))
- return b.String()
+func messageHTMLPlaceholder(m *model.Message) string {
+ content := m.PlainTextContent()
+ return placeholderPattern.ReplaceAllStringFunc(content, func(s string) string {
+ matches := placeholderPattern.FindStringSubmatch(s)
+ if len(matches) != 3 { return template.HTMLEscapeString(s) }
+ fullLabel := matches[1]
+ url := matches[2]
+ left := fullLabel
+ rest := ""
+ if p := strings.Index(fullLabel, "|") ; p >= 0 {
+ left = fullLabel[:p]
+ rest = fullLabel[p+1:]
+ }
+ className := "media"
+ if left == "动画表情" || left == "GIF表情" || strings.Contains(left, "表情") {
+ className = "media anim"
+ }
+ var anchorText string
+ if left == "链接" { // 保留完整形式 [链接|标题\n更多说明]
+ escapedFull := template.HTMLEscapeString(fullLabel)
+ escapedFull = strings.ReplaceAll(escapedFull, "\r", "")
+ escapedFull = strings.ReplaceAll(escapedFull, "\n", "
")
+ anchorText = "[" + escapedFull + "]"
+ } else if left == "文件" && rest != "" { // 文件保留文件名
+ anchorText = "[文件]" + template.HTMLEscapeString(rest)
+ } else {
+ anchorText = "[" + template.HTMLEscapeString(left) + "]"
+ }
+ return `` + anchorText + ``
+ })
}
diff --git a/internal/chatlog/http/static/index.htm b/internal/chatlog/http/static/index.htm
index 5e3904c..483d04a 100644
--- a/internal/chatlog/http/static/index.htm
+++ b/internal/chatlog/http/static/index.htm
@@ -350,6 +350,7 @@
群聊
联系人
聊天记录
+ 日记
@@ -371,6 +372,33 @@
+
+
+
+
+
+
+
+
+
+ 查询最近24小时内“我”发送的消息按会话分组。GET /api/v1/diary
+
+
+
+
+
@@ -755,6 +783,15 @@
if (sessionFormat)
params.append("format", sessionFormat);
break;
+ case "diary":
+ url += "diary";
+ const diaryFormat =
+ document.getElementById(
+ "diary-format"
+ ).value;
+ if (diaryFormat)
+ params.append("format", diaryFormat);
+ break;
}
// 添加参数到URL
diff --git a/internal/model/message.go b/internal/model/message.go
index e2601e0..0cb4da9 100644
--- a/internal/model/message.go
+++ b/internal/model/message.go
@@ -5,6 +5,7 @@ import (
"fmt"
"strings"
"time"
+ "strconv"
"github.com/sjzar/chatlog/pkg/util"
)
@@ -366,6 +367,16 @@ func (m *Message) PlainTextContent() string {
return fmt.Sprintf("", m.Contents["host"], strings.Join(keylist, ","))
case MessageTypeVoice:
if voice, ok := m.Contents["voice"]; ok {
+ // 可选时长字段(可能来源于不同表:voicelength/voiceduration/length 秒)
+ var durStr string
+ if d, ok2 := m.Contents["voicelength"]; ok2 { durStr = fmt.Sprint(d) }
+ if d, ok2 := m.Contents["voiceduration"]; ok2 { durStr = fmt.Sprint(d) }
+ if d, ok2 := m.Contents["length"]; ok2 && durStr=="" { durStr = fmt.Sprint(d) }
+ if durStr != "" {
+ // 规范成整数秒显示
+ if strings.Contains(durStr, ".") { if f,err:=strconv.ParseFloat(durStr,64); err==nil { durStr = fmt.Sprint(int(f+0.5)) } }
+ return fmt.Sprintf("[语音(%ss)](http://%s/voice/%s)", durStr, m.Contents["host"], voice)
+ }
return fmt.Sprintf("[语音](http://%s/voice/%s)", m.Contents["host"], voice)
}
return "[语音]"