feat(mcp): 重构 MCP 服务并修复部分问题(CSV & Voice) (#214)
* fix voice requests not responding * update mcp protocol * support csv format
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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: "获取与特定联系人或群聊的聊天记录",
|
||||
}
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user