diff --git a/go.mod b/go.mod index 70a2e4f..87074f7 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/gin-gonic/gin v1.10.1 github.com/google/uuid v1.6.0 github.com/klauspost/compress v1.18.0 + github.com/mark3labs/mcp-go v0.38.0 github.com/mattn/go-sqlite3 v1.14.32 github.com/mitchellh/mapstructure v1.5.0 github.com/pierrec/lz4/v4 v4.1.22 @@ -28,6 +29,8 @@ require ( ) require ( + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect @@ -42,11 +45,13 @@ require ( github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/invopop/jsonschema v0.13.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect + github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect @@ -65,6 +70,8 @@ require ( github.com/tklauser/numcpus v0.10.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect golang.org/x/arch v0.20.0 // indirect golang.org/x/net v0.43.0 // indirect diff --git a/go.sum b/go.sum index 9dbdda2..27f66ae 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,10 @@ github.com/Eyevinn/mp4ff v0.49.0 h1:00eRg5/KwcLGWUbv+hlifldf74qg46G1IxoWXHrgDvw= github.com/Eyevinn/mp4ff v0.49.0/go.mod h1:hJNUUqOBryLAzUW9wpCJyw2HaI+TCd2rUPhafoS5lgg= github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= @@ -57,7 +61,10 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= @@ -74,6 +81,10 @@ github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc= github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mark3labs/mcp-go v0.38.0 h1:E5tmJiIXkhwlV0pLAwAT0O5ZjUZSISE/2Jxg+6vpq4I= +github.com/mark3labs/mcp-go v0.38.0/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= @@ -158,6 +169,10 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= diff --git a/internal/chatlog/http/mcp.go b/internal/chatlog/http/mcp.go new file mode 100644 index 0000000..7c12156 --- /dev/null +++ b/internal/chatlog/http/mcp.go @@ -0,0 +1,328 @@ +package http + +import ( + "bytes" + "context" + "fmt" + "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/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.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 +注意:此工具不需要任何输入参数,直接调用即可获取当前时间。`), +) + +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 +} diff --git a/internal/chatlog/http/route.go b/internal/chatlog/http/route.go index f7892a4..08e0358 100644 --- a/internal/chatlog/http/route.go +++ b/internal/chatlog/http/route.go @@ -2,6 +2,7 @@ package http import ( "embed" + "encoding/csv" "fmt" "io/fs" "net/http" @@ -9,12 +10,12 @@ import ( "path/filepath" "strings" + "github.com/gin-gonic/gin" + "github.com/sjzar/chatlog/internal/errors" "github.com/sjzar/chatlog/pkg/util" "github.com/sjzar/chatlog/pkg/util/dat2img" "github.com/sjzar/chatlog/pkg/util/silk" - - "github.com/gin-gonic/gin" ) // EFS holds embedded file system data for static assets. @@ -23,43 +24,54 @@ import ( var EFS embed.FS func (s *Service) initRouter() { + s.initBaseRouter() + s.initMediaRouter() + s.initAPIRouter() + s.initMCPRouter() +} - router := s.GetRouter() - +func (s *Service) initBaseRouter() { staticDir, _ := fs.Sub(EFS, "static") - router.StaticFS("/static", http.FS(staticDir)) - router.StaticFileFS("/favicon.ico", "./favicon.ico", http.FS(staticDir)) - router.StaticFileFS("/", "./index.htm", http.FS(staticDir)) - router.GET("/health", func(ctx *gin.Context) { + + 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"}) }) - // Media - router.GET("/image/*key", s.GetImage) - router.GET("/video/*key", s.GetVideo) - router.GET("/file/*key", s.GetFile) - router.GET("/voice/*key", s.GetVoice) - router.GET("/data/*path", s.GetMediaData) + s.router.NoRoute(s.NoRoute) +} - // MCP Server +func (s *Service) initMediaRouter() { + s.router.GET("/image/*key", func(c *gin.Context) { s.handleMedia(c, "image") }) + s.router.GET("/video/*key", func(c *gin.Context) { s.handleMedia(c, "video") }) + s.router.GET("/file/*key", func(c *gin.Context) { s.handleMedia(c, "file") }) + s.router.GET("/voice/*key", func(c *gin.Context) { s.handleMedia(c, "voice") }) + s.router.GET("/data/*path", s.handleMediaData) +} + +func (s *Service) initAPIRouter() { + api := s.router.Group("/api/v1", s.checkDBStateMiddleware()) { - router.GET("/sse", s.mcp.HandleSSE) - router.POST("/messages", s.mcp.HandleMessages) - // mcp inspector is shit - // https://github.com/modelcontextprotocol/inspector/blob/aeaf32f/server/src/index.ts#L155 - router.POST("/message", s.mcp.HandleMessages) + api.GET("/chatlog", s.handleChatlog) + api.GET("/contact", s.handleContacts) + api.GET("/chatroom", s.handleChatRooms) + api.GET("/session", s.handleSessions) } +} - // API V1 Router - api := router.Group("/api/v1", s.checkDBStateMiddleware()) - { - api.GET("/chatlog", s.GetChatlog) - api.GET("/contact", s.GetContacts) - api.GET("/chatroom", s.GetChatRooms) - api.GET("/session", s.GetSessions) - } - - router.NoRoute(s.NoRoute) +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" @@ -75,7 +87,7 @@ func (s *Service) NoRoute(c *gin.Context) { } } -func (s *Service) GetChatlog(c *gin.Context) { +func (s *Service) handleChatlog(c *gin.Context) { q := struct { Time string `form:"time"` @@ -113,6 +125,18 @@ func (s *Service) GetChatlog(c *gin.Context) { switch strings.ToLower(q.Format) { 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": // json c.JSON(http.StatusOK, messages) @@ -131,7 +155,7 @@ func (s *Service) GetChatlog(c *gin.Context) { } } -func (s *Service) GetContacts(c *gin.Context) { +func (s *Service) handleContacts(c *gin.Context) { q := struct { Keyword string `form:"keyword"` @@ -176,7 +200,7 @@ func (s *Service) GetContacts(c *gin.Context) { } } -func (s *Service) GetChatRooms(c *gin.Context) { +func (s *Service) handleChatRooms(c *gin.Context) { q := struct { Keyword string `form:"keyword"` @@ -220,7 +244,7 @@ func (s *Service) GetChatRooms(c *gin.Context) { } } -func (s *Service) GetSessions(c *gin.Context) { +func (s *Service) handleSessions(c *gin.Context) { q := struct { Keyword string `form:"keyword"` @@ -268,22 +292,7 @@ func (s *Service) GetSessions(c *gin.Context) { } } -func (s *Service) GetImage(c *gin.Context) { - s.GetMedia(c, "image") -} - -func (s *Service) GetVideo(c *gin.Context) { - s.GetMedia(c, "video") -} - -func (s *Service) GetFile(c *gin.Context) { - s.GetMedia(c, "file") -} -func (s *Service) GetVoice(c *gin.Context) { - s.GetMedia(c, "voice") -} - -func (s *Service) GetMedia(c *gin.Context, _type string) { +func (s *Service) handleMedia(c *gin.Context, _type string) { key := strings.TrimPrefix(c.Param("key"), "/") if key == "" { errors.Err(c, errors.InvalidArg(key)) @@ -300,11 +309,10 @@ func (s *Service) GetMedia(c *gin.Context, _type string) { for _, k := range keys { if len(k) != 32 { absolutePath := filepath.Join(s.conf.GetDataDir(), k) - if _, err := os.Stat(absolutePath); os.IsNotExist(err) { - continue + if _, err := os.Stat(absolutePath); err == nil { + c.Redirect(http.StatusFound, "/data/"+k) + return } - c.Redirect(http.StatusFound, "/data/"+k) - return } media, err := s.db.GetMedia(_type, k) if err != nil { @@ -331,7 +339,7 @@ func (s *Service) GetMedia(c *gin.Context, _type string) { } } -func (s *Service) GetMediaData(c *gin.Context) { +func (s *Service) handleMediaData(c *gin.Context) { relativePath := filepath.Clean(c.Param("path")) absolutePath := filepath.Join(s.conf.GetDataDir(), relativePath) diff --git a/internal/chatlog/http/service.go b/internal/chatlog/http/service.go index dd0c0b2..5b1354b 100644 --- a/internal/chatlog/http/service.go +++ b/internal/chatlog/http/service.go @@ -5,21 +5,24 @@ import ( "net/http" "time" - "github.com/sjzar/chatlog/internal/chatlog/database" - "github.com/sjzar/chatlog/internal/chatlog/mcp" - "github.com/sjzar/chatlog/internal/errors" - "github.com/gin-gonic/gin" + "github.com/mark3labs/mcp-go/server" "github.com/rs/zerolog/log" + + "github.com/sjzar/chatlog/internal/chatlog/database" + "github.com/sjzar/chatlog/internal/errors" ) type Service struct { conf Config db *database.Service - mcp *mcp.Service router *gin.Engine server *http.Server + + mcpServer *server.MCPServer + mcpSSEServer *server.SSEServer + mcpStreamableServer *server.StreamableHTTPServer } type Config interface { @@ -27,7 +30,7 @@ type Config interface { GetDataDir() string } -func NewService(conf Config, db *database.Service, mcp *mcp.Service) *Service { +func NewService(conf Config, db *database.Service) *Service { gin.SetMode(gin.ReleaseMode) router := gin.New() @@ -47,10 +50,10 @@ func NewService(conf Config, db *database.Service, mcp *mcp.Service) *Service { s := &Service{ conf: conf, db: db, - mcp: mcp, router: router, } + s.initMCPServer() s.initRouter() return s } diff --git a/internal/chatlog/manager.go b/internal/chatlog/manager.go index ccd989b..8ce20e7 100644 --- a/internal/chatlog/manager.go +++ b/internal/chatlog/manager.go @@ -11,7 +11,6 @@ import ( "github.com/sjzar/chatlog/internal/chatlog/ctx" "github.com/sjzar/chatlog/internal/chatlog/database" "github.com/sjzar/chatlog/internal/chatlog/http" - "github.com/sjzar/chatlog/internal/chatlog/mcp" "github.com/sjzar/chatlog/internal/chatlog/wechat" iwechat "github.com/sjzar/chatlog/internal/wechat" "github.com/sjzar/chatlog/pkg/config" @@ -28,7 +27,6 @@ type Manager struct { // Services db *database.Service http *http.Service - mcp *mcp.Service wechat *wechat.Service // Terminal UI @@ -51,9 +49,7 @@ func (m *Manager) Run(configPath string) error { m.db = database.NewService(m.ctx) - m.mcp = mcp.NewService(m.db) - - m.http = http.NewService(m.ctx, m.db, m.mcp) + m.http = http.NewService(m.ctx, m.db) m.ctx.WeChatInstances = m.wechat.GetWeChatInstances() if len(m.ctx.WeChatInstances) >= 1 { @@ -106,13 +102,7 @@ func (m *Manager) StartService() error { return err } - if err := m.mcp.Start(); err != nil { - m.db.Stop() // 回滚已启动的服务 - return err - } - if err := m.http.Start(); err != nil { - m.mcp.Stop() // 回滚已启动的服务 m.db.Stop() return err } @@ -148,10 +138,6 @@ func (m *Manager) stopService() error { errs = append(errs, err) } - if err := m.mcp.Stop(); err != nil { - errs = append(errs, err) - } - if err := m.db.Stop(); err != nil { errs = append(errs, err) } @@ -378,9 +364,7 @@ func (m *Manager) CommandHTTPServer(configPath string, cmdConf map[string]any) e m.db = database.NewService(m.sc) - m.mcp = mcp.NewService(m.db) - - m.http = http.NewService(m.sc, m.db, m.mcp) + m.http = http.NewService(m.sc, m.db) if m.sc.GetAutoDecrypt() { if err := m.wechat.StartAutoDecrypt(); err != nil { @@ -419,10 +403,5 @@ func (m *Manager) CommandHTTPServer(configPath string, cmdConf map[string]any) e } }() - if err := m.mcp.Start(); err != nil { - return err - } - defer m.mcp.Stop() - return m.http.ListenAndServe() } diff --git a/internal/chatlog/mcp/const.go b/internal/chatlog/mcp/const.go deleted file mode 100644 index 21b2e6f..0000000 --- a/internal/chatlog/mcp/const.go +++ /dev/null @@ -1,211 +0,0 @@ -package mcp - -import ( - "github.com/sjzar/chatlog/internal/mcp" -) - -// MCPTools 和资源定义 -var ( - InitializeResponse = mcp.InitializeResponse{ - ProtocolVersion: mcp.ProtocolVersion, - Capabilities: mcp.DefaultCapabilities, - ServerInfo: mcp.ServerInfo{ - Name: "chatlog", - Version: "0.0.1", - }, - } - - ToolContact = mcp.Tool{ - Name: "query_contact", - Description: "查询用户的联系人信息。可以通过姓名、备注名或ID进行查询,返回匹配的联系人列表。当用户询问某人的联系方式、想了解联系人信息或需要查找特定联系人时使用此工具。参数为空时,将返回联系人列表", - InputSchema: mcp.ToolSchema{ - Type: "object", - Properties: mcp.M{ - "keyword": mcp.M{ - "type": "string", - "description": "联系人的搜索关键词,可以是姓名、备注名或ID。", - }, - }, - Required: []string{"keyword"}, - }, - } - - ToolChatRoom = mcp.Tool{ - Name: "query_chat_room", - Description: "查询用户参与的群聊信息。可以通过群名称、群ID或相关关键词进行查询,返回匹配的群聊列表。当用户询问群聊信息、想了解某个群的详情或需要查找特定群聊时使用此工具。", - InputSchema: mcp.ToolSchema{ - Type: "object", - Properties: mcp.M{ - "keyword": mcp.M{ - "type": "string", - "description": "群聊的搜索关键词,可以是群名称、群ID或相关描述", - }, - }, - Required: []string{"keyword"}, - }, - } - - ToolRecentChat = mcp.Tool{ - Name: "query_recent_chat", - Description: "查询最近会话列表,包括个人聊天和群聊。当用户想了解最近的聊天记录、查看最近联系过的人或群组时使用此工具。不需要参数,直接返回最近的会话列表。", - InputSchema: mcp.ToolSchema{ - Type: "object", - Properties: mcp.M{}, - }, - } - - ToolChatLog = mcp.Tool{ - Name: "chatlog", - Description: `检索历史聊天记录,可根据时间、对话方、发送者和关键词等条件进行精确查询。当用户需要查找特定信息或想了解与某人/某群的历史交流时使用此工具。 - -【强制多步查询流程!】 -当查询特定话题或特定发送者发言时,必须严格按照以下流程使用,任何偏离都会导致错误的结果: - -步骤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"参数`, - InputSchema: mcp.ToolSchema{ - Type: "object", - Properties: mcp.M{ - "time": mcp.M{ - "type": "string", - "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"`, - }, - "talker": mcp.M{ - "type": "string", - "description": `指定对话方(联系人或群组) -- 可使用ID、昵称或备注名 -- 多个对话方用","分隔,如:"张三,李四,工作群" -- 【重要】这是多步查询中唯一应保留的参数`, - }, - "sender": mcp.M{ - "type": "string", - "description": `指定群聊中的发送者 -- 仅在查询群聊记录时有效 -- 多个发送者用","分隔,如:"张三,李四" -- 可使用ID、昵称或备注名 -【重要】查询特定发送者的消息时: - 1. 第一步:使用sender参数初步定位多个相关消息时间点 - 2. 后续步骤:必须移除sender参数,分别查询每个时间点前后的完整对话 - 3. 错误示例:对所有找到的消息一次性查询大范围上下文 - 4. 正确示例:对每个时间点T分别执行查询"T前后15-30分钟"(不带sender)`, - }, - "keyword": mcp.M{ - "type": "string", - "description": `搜索内容中的关键词 -- 支持正则表达式匹配 -- 【重要】查询特定话题时: - 1. 第一步:使用keyword参数初步定位多个相关消息时间点 - 2. 后续步骤:必须移除keyword参数,分别查询每个时间点前后的完整对话 - 3. 错误示例:对所有找到的关键词消息一次性查询大范围上下文 - 4. 正确示例:对每个时间点T分别执行查询"T前后15-30分钟"(不带keyword)`, - }, - }, - Required: []string{"time", "talker"}, - }, - } - - ToolCurrentTime = mcp.Tool{ - Name: "current_time", - Description: `获取当前系统时间,返回RFC3339格式的时间字符串(包含用户本地时区信息)。 -使用场景: -- 当用户询问"总结今日聊天记录"、"本周都聊了啥"等当前时间问题 -- 当用户提及"昨天"、"上周"、"本月"等相对时间概念,需要确定基准时间点 -- 需要执行依赖当前时间的计算(如"上个月5号我们有开会吗") -返回示例:2025-04-18T21:29:00+08:00 -注意:此工具不需要任何输入参数,直接调用即可获取当前时间。`, - InputSchema: mcp.ToolSchema{ - Type: "object", - Properties: mcp.M{}, - }, - } - - ResourceRecentChat = mcp.Resource{ - Name: "最近会话", - URI: "session://recent", - Description: "获取最近的聊天会话列表", - } - - ResourceTemplateContact = mcp.ResourceTemplate{ - Name: "联系人信息", - URITemplate: "contact://{username}", - Description: "获取指定联系人的详细信息", - } - - ResourceTemplateChatRoom = mcp.ResourceTemplate{ - Name: "群聊信息", - URITemplate: "chatroom://{roomid}", - Description: "获取指定群聊的详细信息", - } - - ResourceTemplateChatlog = mcp.ResourceTemplate{ - Name: "聊天记录", - URITemplate: "chatlog://{talker}/{timeframe}?limit,offset", - Description: "获取与特定联系人或群聊的聊天记录", - } -) diff --git a/internal/chatlog/mcp/service.go b/internal/chatlog/mcp/service.go deleted file mode 100644 index bc4985e..0000000 --- a/internal/chatlog/mcp/service.go +++ /dev/null @@ -1,328 +0,0 @@ -package mcp - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "net/url" - "strings" - "time" - - "github.com/sjzar/chatlog/internal/chatlog/database" - "github.com/sjzar/chatlog/internal/mcp" - "github.com/sjzar/chatlog/pkg/util" - - "github.com/gin-gonic/gin" -) - -type Service struct { - db *database.Service - - mcp *mcp.MCP -} - -func NewService(db *database.Service) *Service { - return &Service{ - db: db, - } -} - -// GetMCP 获取底层MCP实例 -func (s *Service) GetMCP() *mcp.MCP { - return s.mcp -} - -// Start 启动MCP服务 -func (s *Service) Start() error { - s.mcp = mcp.NewMCP() - go s.worker() - return nil -} - -// Stop 停止MCP服务 -func (s *Service) Stop() error { - if s.mcp != nil { - s.mcp.Close() - } - return nil -} - -// worker 处理MCP请求 -func (s *Service) worker() { - for { - select { - case p, ok := <-s.mcp.ProcessChan: - if !ok { - return - } - s.processMCP(p.Session, p.Request) - } - } -} - -func (s *Service) HandleSSE(c *gin.Context) { - s.mcp.HandleSSE(c) -} - -func (s *Service) HandleMessages(c *gin.Context) { - s.mcp.HandleMessages(c) -} - -// processMCP 处理MCP请求 -func (s *Service) processMCP(session *mcp.Session, req *mcp.Request) { - var err error - switch req.Method { - case mcp.MethodInitialize: - err = s.initialize(session, req) - case mcp.MethodToolsList: - err = s.sendCustomParams(session, req, mcp.M{"tools": []mcp.Tool{ - ToolContact, - ToolChatRoom, - ToolRecentChat, - ToolChatLog, - ToolCurrentTime, - }}) - case mcp.MethodToolsCall: - err = s.toolsCall(session, req) - case mcp.MethodPromptsList: - err = s.sendCustomParams(session, req, mcp.M{"prompts": []mcp.Prompt{}}) - case mcp.MethodResourcesList: - err = s.sendCustomParams(session, req, mcp.M{"resources": []mcp.Resource{ - ResourceRecentChat, - }}) - case mcp.MethodResourcesTemplateList: - err = s.sendCustomParams(session, req, mcp.M{"resourceTemplates": []mcp.ResourceTemplate{ - ResourceTemplateContact, - ResourceTemplateChatRoom, - ResourceTemplateChatlog, - }}) - case mcp.MethodResourcesRead: - err = s.resourcesRead(session, req) - case mcp.MethodPing: - err = s.sendCustomParams(session, req, struct{}{}) - } - - if err != nil { - session.WriteError(req, err) - } -} - -// initialize 处理初始化请求 -func (s *Service) initialize(session *mcp.Session, req *mcp.Request) error { - initReq, err := parseParams[mcp.InitializeRequest](req.Params) - if err != nil { - return fmt.Errorf("解析初始化参数失败: %v", err) - } - session.SaveClientInfo(initReq.ClientInfo) - - return session.WriteResponse(req, InitializeResponse) -} - -// toolsCall 处理工具调用 -func (s *Service) toolsCall(session *mcp.Session, req *mcp.Request) error { - callReq, err := parseParams[mcp.ToolsCallRequest](req.Params) - if err != nil { - return fmt.Errorf("解析工具调用参数失败: %v", err) - } - - buf := &bytes.Buffer{} - switch callReq.Name { - case "query_contact": - keyword := "" - if v, ok := callReq.Arguments["keyword"]; ok { - keyword = v.(string) - } - limit := util.MustAnyToInt(callReq.Arguments["limit"]) - offset := util.MustAnyToInt(callReq.Arguments["offset"]) - list, err := s.db.GetContacts(keyword, limit, offset) - if err != nil { - return fmt.Errorf("无法获取联系人列表: %v", err) - } - 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)) - } - case "query_chat_room": - keyword := "" - if v, ok := callReq.Arguments["keyword"]; ok { - keyword = v.(string) - } - limit := util.MustAnyToInt(callReq.Arguments["limit"]) - offset := util.MustAnyToInt(callReq.Arguments["offset"]) - list, err := s.db.GetChatRooms(keyword, limit, offset) - if err != nil { - return fmt.Errorf("无法获取群聊列表: %v", err) - } - 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))) - } - case "query_recent_chat": - keyword := "" - if v, ok := callReq.Arguments["keyword"]; ok { - keyword = v.(string) - } - limit := util.MustAnyToInt(callReq.Arguments["limit"]) - offset := util.MustAnyToInt(callReq.Arguments["offset"]) - data, err := s.db.GetSessions(keyword, limit, offset) - if err != nil { - return fmt.Errorf("无法获取会话列表: %v", err) - } - for _, session := range data.Items { - buf.WriteString(session.PlainText(120)) - buf.WriteString("\n") - } - case "chatlog": - if callReq.Arguments == nil { - return mcp.ErrInvalidParams - } - _time := "" - if v, ok := callReq.Arguments["time"]; ok { - _time = v.(string) - } - start, end, ok := util.TimeRangeOf(_time) - if !ok { - return fmt.Errorf("无法解析时间范围") - } - talker := "" - if v, ok := callReq.Arguments["talker"]; ok { - talker = v.(string) - } - sender := "" - if v, ok := callReq.Arguments["sender"]; ok { - sender = v.(string) - } - keyword := "" - if v, ok := callReq.Arguments["keyword"]; ok { - keyword = v.(string) - } - limit := util.MustAnyToInt(callReq.Arguments["limit"]) - offset := util.MustAnyToInt(callReq.Arguments["offset"]) - messages, err := s.db.GetMessages(start, end, talker, sender, keyword, limit, offset) - if err != nil { - return fmt.Errorf("无法获取聊天记录: %v", err) - } - if len(messages) == 0 { - buf.WriteString("未找到符合查询条件的聊天记录") - } - for _, m := range messages { - buf.WriteString(m.PlainText(strings.Contains(talker, ","), util.PerfectTimeFormat(start, end), "")) - buf.WriteString("\n") - } - case "current_time": - buf.WriteString(time.Now().Local().Format(time.RFC3339)) - default: - return fmt.Errorf("未支持的工具: %s", callReq.Name) - } - - resp := mcp.ToolsCallResponse{ - Content: []mcp.Content{ - {Type: "text", Text: buf.String()}, - }, - IsError: false, - } - return session.WriteResponse(req, resp) -} - -// resourcesRead 处理资源读取 -func (s *Service) resourcesRead(session *mcp.Session, req *mcp.Request) error { - readReq, err := parseParams[mcp.ResourcesReadRequest](req.Params) - if err != nil { - return fmt.Errorf("解析资源读取参数失败: %v", err) - } - - u, err := url.Parse(readReq.URI) - if err != nil { - return fmt.Errorf("无法解析URI: %v", err) - } - - buf := &bytes.Buffer{} - switch u.Scheme { - case "contact": - list, err := s.db.GetContacts(u.Host, 0, 0) - if err != nil { - return fmt.Errorf("无法获取联系人列表: %v", err) - } - 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)) - } - case "chatroom": - list, err := s.db.GetChatRooms(u.Host, 0, 0) - if err != nil { - return fmt.Errorf("无法获取群聊列表: %v", err) - } - 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))) - } - case "session": - data, err := s.db.GetSessions("", 0, 0) - if err != nil { - return fmt.Errorf("无法获取会话列表: %v", err) - } - for _, session := range data.Items { - buf.WriteString(session.PlainText(120)) - buf.WriteString("\n") - } - case "chatlog": - start, end, ok := util.TimeRangeOf(strings.TrimPrefix(u.Path, "/")) - if !ok { - return fmt.Errorf("无法解析时间范围") - } - limit := util.MustAnyToInt(u.Query().Get("limit")) - offset := util.MustAnyToInt(u.Query().Get("offset")) - messages, err := s.db.GetMessages(start, end, u.Host, "", "", limit, offset) - if err != nil { - return fmt.Errorf("无法获取聊天记录: %v", err) - } - if len(messages) == 0 { - buf.WriteString("未找到符合查询条件的聊天记录") - } - for _, m := range messages { - buf.WriteString(m.PlainText(strings.Contains(u.Host, ","), util.PerfectTimeFormat(start, end), "")) - buf.WriteString("\n") - } - default: - return fmt.Errorf("不支持的URI: %s", readReq.URI) - } - - resp := mcp.ReadingResource{ - Contents: []mcp.ReadingResourceContent{ - {URI: readReq.URI, Text: buf.String()}, - }, - } - return session.WriteResponse(req, resp) -} - -// sendCustomParams 发送自定义参数 -func (s *Service) sendCustomParams(session *mcp.Session, req *mcp.Request, params interface{}) error { - b, err := json.Marshal(mcp.NewResponse(req.ID, params)) - if err != nil { - return fmt.Errorf("无法序列化响应: %v", err) - } - session.Write(b) - return nil -} - -// parseParams 解析参数 -func parseParams[T any](params interface{}) (*T, error) { - if params == nil { - return nil, errors.New("params is nil") - } - - // 将 params 重新编码为 JSON - jsonData, err := json.Marshal(params) - if err != nil { - return nil, fmt.Errorf("无法编码 params: %v", err) - } - - // 解码到目标结构体 - var result T - if err := json.Unmarshal(jsonData, &result); err != nil { - return nil, fmt.Errorf("无法解码为目标结构体: %v", err) - } - - return &result, nil -} diff --git a/internal/errors/mcp.go b/internal/errors/mcp.go new file mode 100644 index 0000000..38d930b --- /dev/null +++ b/internal/errors/mcp.go @@ -0,0 +1,15 @@ +package errors + +import "github.com/mark3labs/mcp-go/mcp" + +func ErrMCPTool(err error) *mcp.CallToolResult { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: err.Error(), + }, + }, + IsError: true, + } +} diff --git a/internal/model/message.go b/internal/model/message.go index 8cb4d05..316e827 100644 --- a/internal/model/message.go +++ b/internal/model/message.go @@ -379,3 +379,15 @@ func (m *Message) PlainTextContent() string { return fmt.Sprintf("Type: %d Content: %s", m.Type, content) } } + +func (m *Message) CSV(host string) []string { + m.SetContent("host", host) + return []string{ + m.Time.Format("2006-01-02 15:04:05"), + m.SenderName, + m.Sender, + m.TalkerName, + m.Talker, + m.PlainTextContent(), + } +}