├── .gitignore ├── README.md ├── bootstrap └── httpserve.go ├── config.yaml ├── go.mod ├── go.sum ├── internal ├── config │ └── config.go ├── handler │ ├── check.go │ ├── test.go │ └── user.go └── service │ ├── model │ ├── common.go │ └── message.go │ └── wechat │ └── msg.go └── main.go /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | .DS_Store 3 | build.sh 4 | log/* 5 | test.html 6 | myconfig.yaml 7 | wechat-ai-amd64 8 | wechat-ai-arm64 9 | chat/* -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### 之前是对接 OpenAI 的,太老了,重写一下。 2 | 3 | ### 一、介绍 4 | - 这是一个用于**公众号对接大模型聊天**的项目,仅供简单角色扮演聊天娱乐,**不适用于知识问答**,毕竟已经大模型遍地了。 5 | - 该项目最初版本是对接 OpenAI 开发的,彼时调用需要一些魔法且速度不快,现在已经没有此类问题 6 | - 存在问题:微信限制,只能一问一答且15秒超时限制,如果15秒内不返回结果则无法主动推送。所以**建议使用速度较快的模型**。 7 | - 体验。关注公众号`杠点杠`尝试提问,这仅是个人娱乐号,不推送。 8 | 9 | ### 二、特性 10 | - [x] 优化微信被动回复超时问题。(微信是每次5秒,询问3次,即最大15秒) 11 | - [x] 支持参数调节,人物预设、滑动记录聊天次数、单次回复长度预估、温度等 12 | - [x] 支持上下文。(在`chat`文件夹记录不同用户聊天内容,可以自己定期删除) 13 | - [x] 无需数据库,使用`json`格式文件记录聊天,可自行查看和清理。 14 | - [ ] 才知道公众号已经[取消语音消息转文字能力](https://developers.weixin.qq.com/community/minihome/doc/0004826962c5c81c0540cb9e365401?page=1),所以该功能不支持。 15 | 16 | ### 三、部署 17 | - 拷贝配置文件 `config.yaml` 18 | - 配置大模型 (尽量使用小的模型,以增加速度) 19 | - 阿里百炼 20 | - 申请Key https://bailian.console.aliyun.com/?apiKey=1#/api-key 21 | - 模型列表 https://help.aliyun.com/zh/model-studio/getting-started/models 22 | - 字节火山引擎 23 | - 申请Key https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey 24 | - 模型列表 https://console.volcengine.com/ark/region:ark+cn-beijing/model 25 | - DeepSeek (不推荐,没有小模型,速度比较慢。非要使用可以用阿里或者字节的deepseek大模型) 26 | - 申请Key: https://platform.deepseek.com/api_keys 27 | - 模型: deepseek-reason (R1) 、deepseek-chat (V3) 28 | - 配置微信公众号`令牌Token`:[微信公众平台](https://mp.weixin.qq.com/)->设置与开发->开发接口管理->基本配置->令牌(Token) 29 | 30 | - 部署服务。下载右侧 Releases 中的二进制文件与 `config.yaml` 同目录,直接执行即可。 (使用`nohup ./wechat-ai-amd64 >> ./data.log 2>&1 &` 后台运行) 31 | 32 | - 配置公众号服务器地址(URL)。 填写 `http://服务器IP/wx`(该连接勿手动调用),设置明文方式传输,提交后,点击「启用」。 (初次启用可能要等一会生效) 33 | - 如果帮到你,请麻烦给个star。 34 | - 有事加我QQ `772532526` 35 | 36 | -------------------------------------------------------------------------------- /bootstrap/httpserve.go: -------------------------------------------------------------------------------- 1 | package bootstrap 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | ) 7 | 8 | var ( 9 | _ http.Handler = (*Engine)(nil) 10 | ) 11 | 12 | type HandlerFunc func(http.ResponseWriter, *http.Request) 13 | 14 | type Engine struct { 15 | router map[string]HandlerFunc 16 | } 17 | 18 | func New() *Engine { 19 | return &Engine{router: make(map[string]HandlerFunc)} 20 | } 21 | 22 | func (engine *Engine) addRoute(method string, pattern string, handler HandlerFunc) { 23 | key := method + "-" + pattern 24 | engine.router[key] = handler 25 | } 26 | 27 | func (engine *Engine) GET(pattern string, handler HandlerFunc) { 28 | engine.addRoute(http.MethodGet, pattern, handler) 29 | } 30 | 31 | func (engine *Engine) POST(pattern string, handler HandlerFunc) { 32 | engine.addRoute(http.MethodPost, pattern, handler) 33 | } 34 | 35 | func (engine *Engine) Run(addr string) (err error) { 36 | return http.ListenAndServe(addr, engine) 37 | } 38 | 39 | func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) { 40 | key := req.Method + "-" + req.URL.Path 41 | if handler, ok := engine.router[key]; ok { 42 | handler(w, req) 43 | } else { 44 | fmt.Fprintf(w, "404 NOT FOUND: %s\n", req.URL) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /config.yaml: -------------------------------------------------------------------------------- 1 | # 服务端口(公众号要求必须80或者443,如果你不使用80端口,需要自己再配置一层代理) 2 | port: 80 3 | 4 | # 大模型配置 5 | llm: 6 | api: https://dashscope.aliyuncs.com/compatible-mode/v1 7 | key: 8 | model: qwen-turbo 9 | 10 | # 人物预设 11 | prompt: 你是一只可爱的小猫咪,请每句话都带‘喵~’来回复我。 12 | # 温度 0-2 13 | temperature: 1.2 14 | # 单次回复最大token 1-8192, 设置小一点可以减少回复时间 15 | maxtokens: 300 16 | # 记忆最近几次对话(一问一答为一次),<=0 表示不记忆 17 | history: 4 18 | 19 | # 公众号配置 https://mp.weixin.qq.com 20 | wechat: 21 | # 必填(公众号服务). 与公众号设置保持一致 22 | token: 23 | # 用户关注时主动发送的消息 24 | subscribeMsg: 你好,我是一只可爱的小猫咪,谢谢关注。 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module wechat-ai 2 | 3 | go 1.18.0 4 | 5 | require github.com/spf13/viper v1.19.0 6 | 7 | require ( 8 | github.com/fsnotify/fsnotify v1.7.0 // indirect 9 | github.com/hashicorp/hcl v1.0.0 // indirect 10 | github.com/magiconair/properties v1.8.7 // indirect 11 | github.com/mitchellh/mapstructure v1.5.0 // indirect 12 | github.com/pelletier/go-toml/v2 v2.2.2 // indirect 13 | github.com/sagikazarmark/locafero v0.4.0 // indirect 14 | github.com/sagikazarmark/slog-shim v0.1.0 // indirect 15 | github.com/sourcegraph/conc v0.3.0 // indirect 16 | github.com/spf13/afero v1.11.0 // indirect 17 | github.com/spf13/cast v1.6.0 // indirect 18 | github.com/spf13/pflag v1.0.5 // indirect 19 | github.com/subosito/gotenv v1.6.0 // indirect 20 | go.uber.org/atomic v1.9.0 // indirect 21 | go.uber.org/multierr v1.9.0 // indirect 22 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect 23 | golang.org/x/sys v0.18.0 // indirect 24 | golang.org/x/text v0.14.0 // indirect 25 | gopkg.in/ini.v1 v1.67.0 // indirect 26 | gopkg.in/yaml.v3 v3.0.1 // indirect 27 | ) 28 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 4 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 6 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 7 | github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= 8 | github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= 9 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 10 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 11 | github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= 12 | github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 13 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 14 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 15 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 16 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 17 | github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= 18 | github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= 19 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 20 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 21 | github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= 22 | github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= 23 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 24 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 25 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 26 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 27 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 28 | github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= 29 | github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= 30 | github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= 31 | github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= 32 | github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= 33 | github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= 34 | github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= 35 | github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= 36 | github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= 37 | github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 38 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 39 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 40 | github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= 41 | github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= 42 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 43 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 44 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 45 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 46 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 47 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 48 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 49 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 50 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 51 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 52 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 53 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 54 | go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= 55 | go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 56 | go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= 57 | go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= 58 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= 59 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= 60 | golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= 61 | golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 62 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 63 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 64 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 65 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 66 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 67 | gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= 68 | gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= 69 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 70 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 71 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 72 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | "os" 7 | 8 | "github.com/spf13/viper" 9 | ) 10 | 11 | var ( 12 | Port string 13 | 14 | LLM struct { 15 | Api string 16 | Key string 17 | Model string 18 | 19 | Prompt string 20 | Temperature float32 21 | MaxTokens uint32 22 | History int16 23 | } 24 | 25 | Wechat struct { 26 | Token string 27 | SubscribeMsg string 28 | } 29 | ) 30 | 31 | func init() { 32 | // 使用flag接收 config文件 33 | configFile := flag.String("c", "./config.yaml", "配置文件名") 34 | flag.Parse() 35 | 36 | // 读取配置 37 | viper.SetConfigFile(*configFile) 38 | viper.SetConfigType("yaml") 39 | 40 | err := viper.ReadInConfig() 41 | if err != nil { 42 | log.Println("解析配置文件config.yaml失败:", err.Error()) 43 | os.Exit(0) 44 | } 45 | 46 | viper.UnmarshalKey("port", &Port) 47 | viper.UnmarshalKey("llm", &LLM) 48 | viper.UnmarshalKey("wechat", &Wechat) 49 | 50 | if LLM.Key == "" || LLM.Api == "" || LLM.Model == "" || LLM.MaxTokens <= 0 { 51 | log.Println("大模型配置错误") 52 | os.Exit(0) 53 | } 54 | 55 | if Wechat.Token == "" { 56 | log.Println("未设置公众号token,公众号功能不可用") 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /internal/handler/check.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "crypto/sha1" 5 | "encoding/hex" 6 | "log" 7 | "net/http" 8 | "sort" 9 | "wechat-ai/internal/config" 10 | ) 11 | 12 | func WechatCheck(w http.ResponseWriter, r *http.Request) { 13 | query := r.URL.Query() 14 | signature := query.Get("signature") 15 | timestamp := query.Get("timestamp") 16 | nonce := query.Get("nonce") 17 | echostr := query.Get("echostr") 18 | 19 | sl := []string{config.Wechat.Token, timestamp, nonce} 20 | sort.Strings(sl) 21 | sum := sha1.Sum([]byte(sl[0] + sl[1] + sl[2])) 22 | ok := signature == hex.EncodeToString(sum[:]) 23 | 24 | if ok { 25 | w.Write([]byte(echostr)) 26 | return 27 | } 28 | 29 | log.Println("此接口为公众号验证,不应该被手动调用,公众号接入校验失败") 30 | } 31 | -------------------------------------------------------------------------------- /internal/handler/test.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "net/http" 5 | "wechat-ai/internal/service/model" 6 | ) 7 | 8 | func Test(w http.ResponseWriter, r *http.Request) { 9 | msg := r.URL.Query().Get("msg") 10 | 11 | reply := model.Chat("test", msg) 12 | w.Header().Set("Content-Type", "application/json; charset=utf-8") 13 | w.WriteHeader(http.StatusOK) 14 | w.Write([]byte(reply)) 15 | } 16 | -------------------------------------------------------------------------------- /internal/handler/user.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "io" 5 | "log" 6 | "net/http" 7 | "sync" 8 | "time" 9 | "wechat-ai/internal/config" 10 | "wechat-ai/internal/service/model" 11 | "wechat-ai/internal/service/wechat" 12 | ) 13 | 14 | // https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Passive_user_reply_message.html 15 | // 微信服务器在五秒内收不到响应会断掉连接,并且重新发起请求,总共重试三次 16 | func ReceiveMsg(w http.ResponseWriter, r *http.Request) { 17 | bs, _ := io.ReadAll(r.Body) 18 | msg := wechat.ParseMsg(bs) 19 | 20 | if msg == nil { 21 | log.Println("xml格式公众号消息接口,请勿手动调用") 22 | wechat.EchoSuccess(w) 23 | return 24 | } 25 | 26 | // 非文本不回复(返回success表示不回复) 27 | switch msg.MsgType { 28 | // 未写的类型 29 | default: 30 | log.Printf("未实现的消息类型%s\n", msg.MsgType) 31 | wechat.EchoSuccess(w) 32 | case "event": 33 | switch msg.Event { 34 | default: 35 | log.Printf("未实现的事件%s\n", msg.Event) 36 | wechat.EchoSuccess(w) 37 | case "subscribe": 38 | msg.EchoText(w, config.Wechat.SubscribeMsg) 39 | return 40 | case "unsubscribe": 41 | log.Println("取消关注:", msg.FromUserName) 42 | wechat.EchoSuccess(w) 43 | return 44 | } 45 | // https://developers.weixin.qq.com/community/minihome/doc/0004826962c5c81c0540cb9e365401?page=1 46 | case "voice": 47 | msg.EchoText(w, "不好意思哈,我听不到语音消息~") 48 | case "text": 49 | 50 | } 51 | 52 | ch := GetUserChan(msg) 53 | 54 | select { 55 | // 前两次超时不回答 56 | case <-time.After(time.Second * 5): 57 | // log.Println("5s超时") 58 | case result := <-ch: 59 | msg.EchoText(w, result) 60 | } 61 | 62 | } 63 | 64 | var ( 65 | replyCache sync.Map 66 | ) 67 | 68 | // 使用chan的目的在于能提前返回 69 | func GetUserChan(msg *wechat.Msg) (ch chan string) { 70 | replyCh, ok := replyCache.Load(msg.MsgId) 71 | if !ok { 72 | ch = make(chan string, 1) 73 | replyCache.Store(msg.MsgId, ch) 74 | 75 | go func(msgid int64) { 76 | resultCh := make(chan string) 77 | go func() { 78 | resultCh <- model.Chat(msg.FromUserName, msg.Content) 79 | }() 80 | 81 | select { 82 | case <-time.After(time.Second * 14): 83 | ch <- "抱歉,无法在微信限制时间内做出应答" 84 | case reply := <-resultCh: 85 | ch <- reply 86 | } 87 | close(ch) 88 | replyCache.Delete(msgid) 89 | }(msg.MsgId) 90 | } else { 91 | ch = replyCh.(chan string) 92 | } 93 | return 94 | } 95 | -------------------------------------------------------------------------------- /internal/service/model/common.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "io" 8 | "log" 9 | "net/http" 10 | "strings" 11 | "time" 12 | "wechat-ai/internal/config" 13 | ) 14 | 15 | type Request struct { 16 | Model string `json:"model"` 17 | Messages []RequestMessage `json:"messages"` 18 | Temperature float32 `json:"temperature"` 19 | MaxTokens uint32 `json:"max_tokens"` 20 | } 21 | 22 | type RequestMessage struct { 23 | Role string `json:"role"` 24 | Content string `json:"content"` 25 | } 26 | 27 | type Response struct { 28 | ID string `json:"id"` 29 | Choices []struct { 30 | // 流式 31 | // Delta struct { 32 | // Content string `json:"content"` 33 | // } `json:"delta,omitempty"` 34 | // json 35 | Message struct { 36 | Role string `json:"role"` 37 | Content string `json:"content"` 38 | } `json:"message,omitempty"` 39 | // json 40 | Usage struct { 41 | PromptTokens uint16 `json:"prompt_tokens"` 42 | PromptCacheHitTokens uint16 `json:"prompt_cache_hit_tokens"` 43 | CompletionTokens uint16 `json:"completion_tokens"` 44 | } `json:"usage,omitempty"` 45 | } `json:"choices"` 46 | } 47 | 48 | func Chat(uid string, msg string) string { 49 | messages := getMessages(uid) 50 | 51 | // 第一次说话,加上预设的prompt 52 | if len(messages) == 0 && config.LLM.Prompt != "" { 53 | addMessages(uid, "system", config.LLM.Prompt) 54 | messages = append(messages, RequestMessage{Role: "system", Content: config.LLM.Prompt}) 55 | } 56 | 57 | if len(messages) > 0 { 58 | if config.LLM.History <= 0 { 59 | messages = messages[:1] 60 | } else { 61 | n := int(config.LLM.History * 2) 62 | size := len(messages) 63 | if n < size-1 { 64 | copy(messages[1:], messages[size-n:]) 65 | messages = messages[:n+1] 66 | } 67 | } 68 | } 69 | 70 | messages = append(messages, RequestMessage{Role: "user", Content: msg}) 71 | 72 | req := Request{ 73 | Model: config.LLM.Model, 74 | Messages: messages, 75 | Temperature: config.LLM.Temperature, 76 | MaxTokens: config.LLM.MaxTokens, 77 | } 78 | data, _ := json.Marshal(&req) 79 | 80 | url := strings.TrimSuffix(config.LLM.Api, "/") + "/chat/completions" 81 | echo, err := post(url, config.LLM.Key, data) 82 | if err != nil { 83 | log.Println("发起请求失败:" + err.Error()) 84 | log.Println("请求失败数据:" + string(data)) 85 | return "抱歉,网络错误" 86 | } 87 | var res Response 88 | err = json.Unmarshal(echo, &res) 89 | if err != nil { 90 | return err.Error() 91 | } 92 | 93 | reply := string(res.Choices[0].Message.Content) 94 | addMessages(uid, "user", msg) 95 | addMessages(uid, "assistant", reply) 96 | 97 | return reply 98 | } 99 | 100 | func post(url, auth string, data []byte) (body []byte, err error) { 101 | client := http.Client{Timeout: time.Second * 50} 102 | req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(data)) 103 | if err != nil { 104 | return nil, err 105 | } 106 | req.Header.Add("Content-Type", "application/json") 107 | req.Header.Add("Authorization", "Bearer "+auth) 108 | 109 | res, err := client.Do(req) 110 | if err != nil { 111 | return nil, err 112 | } 113 | if res.StatusCode != http.StatusOK { 114 | return nil, errors.New("请求发起失败,错误码:" + res.Status) 115 | } 116 | defer res.Body.Close() 117 | body, err = io.ReadAll(res.Body) 118 | if err != nil { 119 | return nil, err 120 | } 121 | return body, nil 122 | } 123 | -------------------------------------------------------------------------------- /internal/service/model/message.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io" 7 | "log" 8 | "os" 9 | "path/filepath" 10 | ) 11 | 12 | func getMessages(uid string) (messages []RequestMessage) { 13 | filename := "./chat/" + uid 14 | hasfile := existsFile(filename) 15 | if !hasfile { 16 | return 17 | } 18 | 19 | f, err := openFile(filename) 20 | if err != nil { 21 | log.Println("[ERROR] 无法打开文件:" + err.Error()) 22 | return 23 | } 24 | defer f.Close() 25 | 26 | // 读取 27 | bs, _ := io.ReadAll(f) 28 | end := bytes.LastIndexByte(bs, ',') 29 | bs = bs[:end] 30 | messagesBytes := make([]byte, len(bs)+2) 31 | messagesBytes[0] = '[' 32 | messagesBytes[len(messagesBytes)-1] = ']' 33 | copy(messagesBytes[1:], bs) 34 | err = json.Unmarshal(messagesBytes, &messages) 35 | if err != nil { 36 | log.Println("[ERROR] 无法解析文件:" + err.Error()) 37 | } 38 | return 39 | } 40 | 41 | func addMessages(uid, role, msg string) { 42 | f, err := openFile("./chat/" + uid) 43 | if err != nil { 44 | return 45 | } 46 | defer f.Close() 47 | b, _ := json.Marshal(RequestMessage{Role: role, Content: msg}) 48 | f.Write(b) 49 | f.Write([]byte(",\n")) 50 | } 51 | 52 | // 判断文件是否存在 53 | func existsFile(filename string) bool { 54 | _, err := os.Stat(filename) 55 | return !os.IsNotExist(err) 56 | } 57 | 58 | // 打开文件 (文件不存在则创建) 59 | func openFile(filename string) (*os.File, error) { 60 | dir := filepath.Dir(filename) 61 | if !existsFile(dir) { 62 | err := os.MkdirAll(dir, os.ModePerm) 63 | if err != nil { 64 | return nil, err 65 | } 66 | } 67 | 68 | return os.OpenFile(filename, os.O_RDWR|os.O_APPEND|os.O_CREATE, 0775) 69 | } 70 | -------------------------------------------------------------------------------- /internal/service/wechat/msg.go: -------------------------------------------------------------------------------- 1 | package wechat 2 | 3 | import ( 4 | "encoding/xml" 5 | "net/http" 6 | "time" 7 | ) 8 | 9 | type Msg struct { 10 | XMLName xml.Name `xml:"xml"` 11 | ToUserName string `xml:"ToUserName"` 12 | FromUserName string `xml:"FromUserName"` 13 | CreateTime int64 `xml:"CreateTime"` 14 | MsgType string `xml:"MsgType"` 15 | Event string `xml:"Event"` 16 | Content string `xml:"Content"` 17 | Recognition string `xml:"Recognition"` 18 | 19 | MsgId int64 `xml:"MsgId,omitempty"` 20 | } 21 | 22 | func ParseMsg(data []byte) *Msg { 23 | var msg Msg 24 | if err := xml.Unmarshal(data, &msg); err != nil { 25 | return nil 26 | } 27 | return &msg 28 | } 29 | 30 | func (msg *Msg) GenerateEchoData(s string) []byte { 31 | data := Msg{ 32 | ToUserName: msg.FromUserName, 33 | FromUserName: msg.ToUserName, 34 | CreateTime: time.Now().Unix(), 35 | MsgType: "text", 36 | Content: s, 37 | } 38 | bs, _ := xml.Marshal(&data) 39 | return bs 40 | } 41 | 42 | var success = []byte("success") 43 | 44 | func EchoSuccess(w http.ResponseWriter) { 45 | w.Header().Set("Content-Type", "application/xml; charset=utf-8") 46 | w.WriteHeader(http.StatusOK) 47 | w.Write(success) 48 | } 49 | 50 | func (msg *Msg) EchoText(w http.ResponseWriter, s string) { 51 | data := Msg{ 52 | ToUserName: msg.FromUserName, 53 | FromUserName: msg.ToUserName, 54 | CreateTime: time.Now().Unix(), 55 | MsgType: "text", 56 | Content: s, 57 | } 58 | bs, _ := xml.Marshal(&data) 59 | w.Header().Set("Content-Type", "application/xml; charset=utf-8") 60 | w.WriteHeader(http.StatusOK) 61 | w.Write(bs) 62 | } 63 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | "wechat-ai/bootstrap" 7 | "wechat-ai/internal/config" 8 | "wechat-ai/internal/handler" 9 | ) 10 | 11 | func init() { 12 | 13 | } 14 | 15 | func main() { 16 | r := bootstrap.New() 17 | 18 | // 微信消息处理 19 | r.POST("/wx", handler.ReceiveMsg) 20 | // 用于公众号自动验证 21 | r.GET("/wx", handler.WechatCheck) 22 | // 用于测试 curl "http://127.0.0.1:$PORT/" 23 | r.GET("/", handler.Test) 24 | 25 | log.Printf("启动服务,测试:curl 'http://127.0.0.1:%s?msg=你好' ", config.Port) 26 | if err := http.ListenAndServe(":"+config.Port, r); err != nil { 27 | panic(err) 28 | } 29 | } 30 | --------------------------------------------------------------------------------