Files

438 lines
16 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package http
import (
"bytes"
"context"
"fmt"
"strconv"
"strings"
"time"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/rs/zerolog/log"
"github.com/sjzar/chatlog/internal/chatlog/conf"
"github.com/sjzar/chatlog/internal/errors"
"github.com/sjzar/chatlog/internal/model"
"github.com/sjzar/chatlog/pkg/util"
"github.com/sjzar/chatlog/pkg/version"
)
func (s *Service) initMCPServer() {
s.mcpServer = server.NewMCPServer(conf.AppName, version.Version)
s.mcpServer.AddTool(ContactTool, s.handleMCPContact)
s.mcpServer.AddTool(ChatRoomTool, s.handleMCPChatRoom)
s.mcpServer.AddTool(RecentChatTool, s.handleMCPRecentChat)
s.mcpServer.AddTool(ChatLogTool, s.handleMCPChatLog)
s.mcpServer.AddTool(CurrentTimeTool, s.handleMCPCurrentTime)
s.mcpServer.AddTool(DiaryTool, s.handleMCPDiary)
s.mcpSSEServer = server.NewSSEServer(s.mcpServer)
s.mcpStreamableServer = server.NewStreamableHTTPServer(s.mcpServer)
}
var ContactTool = mcp.NewTool(
"query_contact",
mcp.WithDescription(`查询用户的联系人信息。可以通过姓名、备注名或ID进行查询,返回匹配的联系人列表。当用户询问某人的联系方式、想了解联系人信息或需要查找特定联系人时使用此工具。参数为空时,将返回联系人列表`),
mcp.WithString("keyword", mcp.Description("联系人的搜索关键词,可以是姓名、备注名或ID。")),
)
var ChatRoomTool = mcp.NewTool(
"query_chat_room",
mcp.WithDescription(`查询用户参与的群聊信息。可以通过群名称、群ID或相关关键词进行查询,返回匹配的群聊列表。当用户询问群聊信息、想了解某个群的详情或需要查找特定群聊时使用此工具。`),
mcp.WithString("keyword", mcp.Description("群聊的搜索关键词,可以是群名称、群ID或相关描述")),
)
var RecentChatTool = mcp.NewTool(
"query_recent_chat",
mcp.WithDescription(`查询最近会话列表,包括个人聊天和群聊。当用户想了解最近的聊天记录、查看最近联系过的人或群组时使用此工具。不需要参数,直接返回最近的会话列表。`),
)
var ChatLogTool = mcp.NewTool(
"query_chat_log",
mcp.WithDescription(`检索历史聊天记录,可根据时间、对话方、发送者和关键词等条件进行精确查询。当用户需要查找特定信息或想了解与某人/某群的历史交流时使用此工具。
【强制多步查询流程!】
当查询特定话题或特定发送者发言时,必须严格按照以下流程使用,任何偏离都会导致错误的结果:
步骤1: 初步定位相关消息
- 使用keyword参数查找特定话题
- 使用sender参数查找特定发送者的消息
- 使用较宽时间范围初步查询
步骤2: 【必须执行】针对每个关键结果点分别获取上下文
- 必须对步骤1返回的每个时间点T1, T2, T3...分别执行独立查询(时间范围接近的消息可以合并为一个查询)
- 每次独立查询必须移除keyword参数
- 每次独立查询必须移除sender参数
- 每次独立查询使用"Tn前后15-30分钟"的窄范围
- 每次独立查询仅保留talker参数
步骤3: 【必须执行】综合分析所有上下文
- 必须等待所有步骤2的查询结果返回后再进行分析
- 必须综合考虑所有上下文信息后再回答用户
【严格执行规则!】
- 禁止仅凭步骤1的结果直接回答用户
- 禁止在步骤2使用过大的时间范围一次性查询所有上下文
- 禁止跳过步骤2或步骤3
- 必须对每个关键结果点分别执行独立的上下文查询
【执行示例】
正确流程示例:
1. 步骤1: chatlog(time="2023-04-01~2023-04-30", talker="工作群", keyword="项目进度")
返回结果: 4月5日、4月12日、4月20日有相关消息
2. 步骤2:
- 查询1: chatlog(time="2023-04-05/09:30~2023-04-05/10:30", talker="工作群") // 注意没有keyword
- 查询2: chatlog(time="2023-04-12/14:00~2023-04-12/15:00", talker="工作群") // 注意没有keyword
- 查询3: chatlog(time="2023-04-20/16:00~2023-04-20/17:00", talker="工作群") // 注意没有keyword
3. 步骤3: 综合分析所有上下文后回答用户
错误流程示例:
- 仅执行步骤1后直接回答
- 步骤2使用time="2023-04-01~2023-04-30"一次性查询
- 步骤2仍然保留keyword或sender参数
【自我检查】回答用户前必须自问:
- 我是否对每个关键时间点都执行了独立的上下文查询?
- 我是否在上下文查询中移除了keyword和sender参数?
- 我是否分析了所有上下文后再回答?
- 如果上述任一问题答案为"否",则必须纠正流程
返回格式:"昵称(ID) 时间\n消息内容\n昵称(ID) 时间\n消息内容"
当查询多个Talker时,返回格式为:"昵称(ID)\n[TalkerName(Talker)] 时间\n消息内容"
重要提示:
1. 当用户询问特定时间段内的聊天记录时,必须使用正确的时间格式,特别是包含小时和分钟的查询
2. 对于"今天下午4点到5点聊了啥"这类查询,正确的时间参数格式应为"2023-04-18/16:00~2023-04-18/17:00"
3. 当用户询问具体群聊中某人的聊天记录时,使用"sender"参数
4. 当用户询问包含特定关键词的聊天记录时,使用"keyword"参数`),
mcp.WithString("time", mcp.Description(`指定查询的时间点或时间范围,格式必须严格遵循以下规则:
【单一时间点格式】
- 精确到日:"2023-04-18"或"20230418"
- 精确到分钟(必须包含斜杠和冒号):"2023-04-18/14:30"或"20230418/14:30"(表示2023年4月18日14点30分)
【时间范围格式】(使用"~"分隔起止时间)
- 日期范围:"2023-04-01~2023-04-18"
- 同一天的时间段:"2023-04-18/14:30~2023-04-18/15:45"
* 表示2023年4月18日14点30分到15点45分之间
【重要提示】包含小时分钟的格式必须使用斜杠和冒号:"/"和":"
正确示例:"2023-04-18/16:30"(4月18日下午4点30分)
错误示例:"2023-04-18 16:30"、"2023-04-18T16:30"
【其他支持的格式】
- 年份:"2023"
- 月份:"2023-04"或"202304"`), mcp.Required()),
mcp.WithString("talker", mcp.Description(`指定对话方(联系人或群组)
- 可使用ID、昵称或备注名
- 多个对话方用","分隔,如:"张三,李四,工作群"
- 【重要】这是多步查询中唯一应保留的参数`), mcp.Required()),
mcp.WithString("sender", mcp.Description(`指定群聊中的发送者
- 仅在查询群聊记录时有效
- 多个发送者用","分隔,如:"张三,李四"
- 可使用ID、昵称或备注名
【重要】查询特定发送者的消息时:
1. 第一步:使用sender参数初步定位多个相关消息时间点
2. 后续步骤:必须移除sender参数,分别查询每个时间点前后的完整对话
3. 错误示例:对所有找到的消息一次性查询大范围上下文
4. 正确示例:对每个时间点T分别执行查询"T前后15-30分钟"(不带sender)`)),
mcp.WithString("keyword", mcp.Description(`搜索内容中的关键词
- 支持正则表达式匹配
- 【重要】查询特定话题时:
1. 第一步:使用keyword参数初步定位多个相关消息时间点
2. 后续步骤:必须移除keyword参数,分别查询每个时间点前后的完整对话
3. 错误示例:对所有找到的关键词消息一次性查询大范围上下文
4. 正确示例:对每个时间点T分别执行查询"T前后15-30分钟"(不带keyword)`)),
)
var CurrentTimeTool = mcp.NewTool(
"current_time",
mcp.WithDescription(`获取当前系统时间,返回RFC3339格式的时间字符串(包含用户本地时区信息)。
使用场景:
- 当用户询问"总结今日聊天记录"、"本周都聊了啥"等当前时间问题
- 当用户提及"昨天"、"上周"、"本月"等相对时间概念,需要确定基准时间点
- 需要执行依赖当前时间的计算(如"上个月5号我们有开会吗")
返回示例:2025-04-18T21:29:00+08:00
注意:此工具不需要任何输入参数,直接调用即可获取当前时间。`),
)
var DiaryTool = mcp.NewTool(
"query_diary",
mcp.WithDescription(`查询最近N小时内(可选:24/48/72)用户参与过(至少有一条由“我”发送)的所有会话的全部消息,并按会话分组返回。适用于:
1. 让助手总结我一天聊了什么
2. 统计我今天在不同群/联系人中的发言
3. 回顾我参与过的讨论上下文
参数:
- hours: 时间范围小时数,可选 24、48、72,默认 24
- talker: 可选,筛选特定会话(支持多个,用","分隔,ID/备注/昵称均可)
返回格式:
若存在会话:
[会话显示名(UserName)] - N条
2025-04-18 09:30:05 我 内容
2025-04-18 09:30:15 张三 内容
...
-----------------------------
若无结果:输出"最近Nh没有我参与的会话"。
注意:此工具返回的是完整原始消息内容(已做必要的占位符文本化),不做额外摘要。若需要摘要,请先调用本工具获取原始数据后再进行总结。`),
mcp.WithString("hours", mcp.Description("时间范围小时数,可选:24、48、72;默认为24")),
mcp.WithString("talker", mcp.Description("可选,会话筛选(多个用','分隔)")),
)
type ContactRequest struct {
Keyword string `json:"keyword"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
func (s *Service) handleMCPContact(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
var req ContactRequest
if err := request.BindArguments(&req); err != nil {
log.Error().Err(err).Msg("Failed to bind arguments")
log.Error().Interface("request", request.GetRawArguments()).Msg("Failed to bind arguments")
return errors.ErrMCPTool(err), nil
}
list, err := s.db.GetContacts(req.Keyword, req.Limit, req.Offset)
if err != nil {
log.Error().Err(err).Msg("Failed to get contacts")
return errors.ErrMCPTool(err), nil
}
buf := &bytes.Buffer{}
buf.WriteString("UserName,Alias,Remark,NickName\n")
for _, contact := range list.Items {
buf.WriteString(fmt.Sprintf("%s,%s,%s,%s\n", contact.UserName, contact.Alias, contact.Remark, contact.NickName))
}
return &mcp.CallToolResult{
Content: []mcp.Content{
mcp.TextContent{
Type: "text",
Text: buf.String(),
},
},
}, nil
}
type ChatRoomRequest struct {
Keyword string `json:"keyword"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
func (s *Service) handleMCPChatRoom(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
var req ChatRoomRequest
if err := request.BindArguments(&req); err != nil {
log.Error().Err(err).Msg("Failed to bind arguments")
log.Error().Interface("request", request.GetRawArguments()).Msg("Failed to bind arguments")
return errors.ErrMCPTool(err), nil
}
list, err := s.db.GetChatRooms(req.Keyword, req.Limit, req.Offset)
if err != nil {
log.Error().Err(err).Msg("Failed to get chat rooms")
return errors.ErrMCPTool(err), nil
}
buf := &bytes.Buffer{}
buf.WriteString("Name,Remark,NickName,Owner,UserCount\n")
for _, chatRoom := range list.Items {
buf.WriteString(fmt.Sprintf("%s,%s,%s,%s,%d\n", chatRoom.Name, chatRoom.Remark, chatRoom.NickName, chatRoom.Owner, len(chatRoom.Users)))
}
return &mcp.CallToolResult{
Content: []mcp.Content{
mcp.TextContent{
Type: "text",
Text: buf.String(),
},
},
}, nil
}
type RecentChatRequest struct {
Keyword string `json:"keyword"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
func (s *Service) handleMCPRecentChat(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
var req RecentChatRequest
if err := request.BindArguments(&req); err != nil {
log.Error().Err(err).Msg("Failed to bind arguments")
log.Error().Interface("request", request.GetRawArguments()).Msg("Failed to bind arguments")
return errors.ErrMCPTool(err), nil
}
data, err := s.db.GetSessions(req.Keyword, req.Limit, req.Offset)
if err != nil {
log.Error().Err(err).Msg("Failed to get sessions")
return errors.ErrMCPTool(err), nil
}
buf := &bytes.Buffer{}
for _, session := range data.Items {
buf.WriteString(session.PlainText(120))
buf.WriteString("\n")
}
return &mcp.CallToolResult{
Content: []mcp.Content{
mcp.TextContent{
Type: "text",
Text: buf.String(),
},
},
}, nil
}
type ChatLogRequest 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"`
}
func (s *Service) handleMCPChatLog(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
var req ChatLogRequest
if err := request.BindArguments(&req); err != nil {
log.Error().Err(err).Msg("Failed to bind arguments")
log.Error().Interface("request", request.GetRawArguments()).Msg("Failed to bind arguments")
return errors.ErrMCPTool(err), nil
}
var err error
start, end, ok := util.TimeRangeOf(req.Time)
if !ok {
log.Error().Err(err).Msg("Failed to get messages")
return errors.ErrMCPTool(err), nil
}
if req.Limit < 0 {
req.Limit = 0
}
if req.Offset < 0 {
req.Offset = 0
}
messages, err := s.db.GetMessages(start, end, req.Talker, req.Sender, req.Keyword, req.Limit, req.Offset)
if err != nil {
log.Error().Err(err).Msg("Failed to get messages")
return errors.ErrMCPTool(err), nil
}
buf := &bytes.Buffer{}
if len(messages) == 0 {
buf.WriteString("未找到符合查询条件的聊天记录")
}
for _, m := range messages {
buf.WriteString(m.PlainText(strings.Contains(req.Talker, ","), util.PerfectTimeFormat(start, end), ""))
buf.WriteString("\n")
}
return &mcp.CallToolResult{
Content: []mcp.Content{
mcp.TextContent{
Type: "text",
Text: buf.String(),
},
},
}, nil
}
func (s *Service) handleMCPCurrentTime(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return &mcp.CallToolResult{
Content: []mcp.Content{
mcp.TextContent{
Type: "text",
Text: time.Now().Local().Format(time.RFC3339),
},
},
}, nil
}
type DiaryRequest struct {
Hours string `json:"hours"`
Talker string `json:"talker"`
}
// handleMCPDiary 实现 DiaryTool 逻辑(支持 hours 与 talker)
func (s *Service) handleMCPDiary(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
var req DiaryRequest
_ = request.BindArguments(&req) // 可选参数,忽略绑定错误
hours := 24
if strings.TrimSpace(req.Hours) != "" {
if v, err := strconv.Atoi(req.Hours); err == nil && (v == 24 || v == 48 || v == 72) {
hours = v
}
}
end := time.Now()
start := end.Add(-time.Duration(hours) * time.Hour)
sessionsResp, err := s.db.GetSessions(req.Talker, 0, 0)
if err != nil {
return &mcp.CallToolResult{Content: []mcp.Content{mcp.TextContent{Type: "text", Text: "获取会话失败: " + err.Error()}}}, nil
}
type grouped struct {
Talker string
TalkerName string
Messages []*model.Message
}
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})
}
buf := &bytes.Buffer{}
if len(groups) == 0 {
buf.WriteString(fmt.Sprintf("最近%dh没有我参与的会话", hours))
} else {
for _, g := range groups {
header := g.Talker
if g.TalkerName != "" {
header = fmt.Sprintf("%s(%s)", g.TalkerName, g.Talker)
}
buf.WriteString(fmt.Sprintf("[%s] - %d条\n", header, len(g.Messages)))
for _, m := range g.Messages {
sender := m.Sender
if m.IsSelf {
sender = "我"
}
if m.SenderName != "" {
sender = fmt.Sprintf("%s(%s)", m.SenderName, sender)
}
buf.WriteString(m.Time.Format("2006-01-02 15:04:05"))
buf.WriteString(" ")
buf.WriteString(sender)
buf.WriteString(" ")
buf.WriteString(m.PlainTextContent())
buf.WriteString("\n")
}
buf.WriteString("-----------------------------\n")
}
}
return &mcp.CallToolResult{Content: []mcp.Content{mcp.TextContent{Type: "text", Text: buf.String()}}}, nil
}