feat(ai): 添加 Redis 支持以增强上下文对话功能

This commit is contained in:
liyp 2024-09-22 16:22:02 +08:00
parent cb0e2647a9
commit 472610e757
12 changed files with 369 additions and 52 deletions

2
.gitignore vendored
View file

@ -4,5 +4,5 @@ test*.json
request.json
config.toml
messages.json
.vscode/settings.json
.vscode
.idea

View file

@ -33,7 +33,8 @@
## 使用说明:
1. 默认的配置端口是5580如果需要修改请修改配置文件里的`APIURL`字段。默认获取以`!`开头的消息,如果需要修改,请修改配置文件里的`Prefix`字段。
当前拥有的插件:
2. 现在加入了`Redis`支持,如果需要使用`Redis`,请在配置文件里配置`REDIS`的相关信息,使用`Redis`可以支持`ai`插件的`Context`功能,即支持上下文对话,同时让机器人拥有更高的扩展性。
3. 当前拥有的插件:
- `ping`:判断程序运行,会响应`Pong!`
- `ip`查询一个ip的详细信息使用方法`ip <域名/ip>`
- `lsp`:请求`api.lolicon.app`获取`Pixiv`壁纸,暂时只简单实现获取随机壁纸。

View file

@ -8,7 +8,14 @@ AllowRole = []
BlockGroup = [] # 群组黑名单
BlockUser = []
Master = []
[OPENAI]
MODEL = "gpt-4o"
OPENAI_API_KEY = "sk-xxxx"
OPENAI_BaseURL = "https://api.openai.com/v1"
PROMPT = "## Role : \nQQ群Linux助手\n\n## Background : \n该角色是一位专门为QQ群提供Linux相关技术支持的助手熟悉各种Linux发行版的使用和问题解决旨在帮助群友解决他们在使用Linux过程中遇到的各种问题。\n\n## Preferences : \n该角色偏好简洁明了的回答风格注重解决方案的精确性和有效性。喜欢友好和尊重的互动方式致力于营造一个技术交流的和谐环境。\n\n## Profile :\n- language: 中文\n- description: 为QQ群友提供Linux相关问题的简洁、精确解决方案\n\n## Goals :\n1. 回答Linux相关问题\n2. 提供简短、精确的解决方案\n3. 鼓励群友提出问题并尊重他们\n\n## Constrains :\n1. 避免回答政治相关内容\n2. 每个问题只回答一次,确保回复简短、精确\n\n## Skills :\n1. 熟悉各种Linux发行版的使用和问题解决\n2. 能够提供简洁明了的技术支持\n3. 友好回复群友的提问\n\n## Examples :\n1. 问题如何查看当前Linux系统的内核版本\n 答案:可以使用命令`uname -r`来查看当前的内核版本。\n \n2. 问题:如何安装一个软件包?\n 答案:可以使用`sudo pacman -S <软件包名>`来安装软件包适用于Archlinux系。\n\n## OutputFormat :\n1. 接收用户输入的问题。\n2. 提供简洁、精确的解决方案。\n\n## Initialization : \n作为 QQ群Linux助手, 拥有 熟悉各种Linux发行版的使用和问题解决, 能够提供简洁明了的技术支持, 友好回复群友的提问, 避免回答政治相关内容, 每个问题只回答一次,确保回复简短、精确, 使用默认 中文 与用户对话,友好的欢迎用户。然后介绍自己,并提示用户输入。\n"
Context = 5
[REDIS]
REDIS_URL = "localhost:6379"
REDIS_PASSWORD = ""
REDIS_DB = 0

4
go.mod
View file

@ -13,9 +13,11 @@ require (
github.com/andybalholm/cascadia v1.3.2 // indirect
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/cloudflare/circl v1.3.7 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
@ -56,7 +58,9 @@ require (
github.com/PuerkitoBio/goquery v1.9.2
github.com/andybalholm/brotli v1.1.0
github.com/gin-gonic/gin v1.10.0
github.com/go-redis/redis/v8 v8.11.5
github.com/imroc/req/v3 v3.44.0
github.com/redis/go-redis/v9 v9.6.1
github.com/sashabaranov/go-openai v1.29.0
github.com/sirupsen/logrus v1.9.3
golang.org/x/net v0.26.0 // indirect

10
go.sum
View file

@ -10,6 +10,10 @@ github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU=
github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
@ -20,6 +24,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
@ -36,6 +42,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
@ -90,6 +98,8 @@ github.com/quic-go/qpack v0.4.0 h1:Cr9BXA1sQS2SmDUWjSofMPNKmvF6IiIfDRmgU0w1ZCo=
github.com/quic-go/qpack v0.4.0/go.mod h1:UZVnYIfi5GRk+zI9UMaCPsmZ2xKJP7XBUvVyT1Knj9A=
github.com/quic-go/quic-go v0.41.0 h1:aD8MmHfgqTURWNJy48IYFg2OnxwHT3JL7ahGs73lb4k=
github.com/quic-go/quic-go v0.41.0/go.mod h1:qCkNjqczPEvgsOnxZ0eCD14lv+B2LHlFAB++CNOh9hA=
github.com/redis/go-redis/v9 v9.6.1 h1:HHDteefn6ZkTtY5fGUE8tj8uy85AHk6zP7CpzIAM0y4=
github.com/redis/go-redis/v9 v9.6.1/go.mod h1:0C0c6ycQsdpVNQpxb1njEQIqkx5UcsM8FJCQLgE9+RA=
github.com/refraction-networking/utls v1.6.3 h1:MFOfRN35sSx6K5AZNIoESsBuBxS2LCgRilRIdHb6fDc=
github.com/refraction-networking/utls v1.6.3/go.mod h1:yil9+7qSl+gBwJqztoQseO6Pr3h62pQoY1lXiNR/FPs=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=

139
test/openai.go Normal file
View file

@ -0,0 +1,139 @@
package main
import (
"bufio"
"context"
"fmt"
"go-bot/tools"
"os"
"strings"
"time"
"github.com/redis/go-redis/v9"
"github.com/sashabaranov/go-openai"
)
var ctx = context.Background()
// 初始化 Redis 客户端
func initRedis() *redis.Client {
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379", // Redis 地址
Password: "", // no password set
DB: 0, // use default DB
})
return rdb
}
// 检查请求频率10秒内只能请求一次
func checkRequestFrequency(rdb *redis.Client, groupID string, qqID string) bool {
key := fmt.Sprintf("last_request:%s:%s", groupID, qqID)
lastRequest, err := rdb.Get(ctx, key).Int64()
println("key:", key)
now := time.Now().Unix()
println("now:", now)
println("lastRequest:", lastRequest)
if err == redis.Nil {
// 键不存在,这是第一次请求
rdb.Set(ctx, key, now, 10*time.Second)
return true
} else if err != nil {
fmt.Println("获取上次请求时间时出错:", err)
return false
}
currentRequest := getContext(rdb, key)
fmt.Println("currentRequest:", currentRequest)
if now-lastRequest < 10 {
fmt.Printf("请求过于频繁。距离上次请求还有 %d 秒。\n", 10-(now-lastRequest))
return false // 频率超限,拒绝请求
}
// 更新最后请求时间,并设置 10 秒的过期时间
rdb.Set(ctx, key, now, 10*time.Second)
return true
}
// 添加对话到上下文超过5条则删除所有上下文
func addToContext(rdb *redis.Client, groupID string, qqID string, message string) {
key := fmt.Sprintf("context:%s:%s", groupID, qqID)
// 如果上下文超过5条删除所有上下文
listLength := rdb.LLen(ctx, key).Val()
if listLength > 5 {
rdb.Del(ctx, key) // 删除该用户的所有上下文
}
rdb.RPush(ctx, key, message) // 添加新消息到列表
}
// 获取当前上下文
func getContext(rdb *redis.Client, key string) []string {
// key :=
context, err := rdb.LRange(ctx, key, 0, -1).Result()
if err != nil {
fmt.Println("Error fetching context:", err)
return nil
}
return context
}
func main() {
// ctx := context.Background()
groupID := "12345"
qqID := "67890"
rdb := initRedis()
OPENAI_API_KEY, OPENAI_BaseURL, MODEL := tools.GetOAIConfig()
oaiconfig := openai.DefaultConfig(OPENAI_API_KEY)
oaiconfig.BaseURL = OPENAI_BaseURL
client := openai.NewClientWithConfig(oaiconfig)
messages := make([]openai.ChatCompletionMessage, 0)
reader := bufio.NewReader(os.Stdin)
fmt.Println("Conversation")
fmt.Println("---------------------")
for {
fmt.Print("-> ")
// 检查请求频率
if !checkRequestFrequency(rdb, groupID, qqID) {
// fmt.Println("请求太频繁,请稍后再试。")
return
}
text, _ := reader.ReadString('\n')
// convert CRLF to LF
text = strings.Replace(text, "\n", "", -1)
messages = append(messages, openai.ChatCompletionMessage{
Role: openai.ChatMessageRoleUser,
Content: text,
})
resp, err := client.CreateChatCompletion(
ctx,
openai.ChatCompletionRequest{
Model: MODEL,
Messages: messages,
},
)
if err != nil {
fmt.Printf("ChatCompletion error: %v\n", err)
continue
}
content := resp.Choices[0].Message.Content
messages = append(messages, openai.ChatCompletionMessage{
Role: openai.ChatMessageRoleAssistant,
Content: content,
})
// 添加新消息到上下文
addToContext(rdb, groupID, qqID, text)
fmt.Println(content)
fmt.Println("---------------------")
fmt.Println(getContext(rdb, "context:"+groupID+":"+qqID))
}
}

51
tools/getOAIConfig.go Normal file
View file

@ -0,0 +1,51 @@
package tools
import (
"go-bot/config"
"log"
)
func GetOAIConfig() (string, string, string, string, int64) {
cfg := config.GetConfig()
// config.PrintConfig(cfg, "")
var OPENAI_API_KEY, OPENAI_BaseURL, MODEL, PROMPT string
var context int64
openai_cfg := cfg["OPENAI"].(map[string]interface{})
if openai_cfg["OPENAI_API_KEY"] != nil {
OPENAI_API_KEY = openai_cfg["OPENAI_API_KEY"].(string)
} else {
log.Println("OPENAI_API_KEY 未配置")
}
if openai_cfg["OPENAI_BaseURL"] != nil {
OPENAI_BaseURL = openai_cfg["OPENAI_BaseURL"].(string)
} else {
log.Println("OPENAI_BaseURL 未配置,使用openai默认配置")
OPENAI_BaseURL = "https://api.openai.com/v1"
}
if openai_cfg["MODEL"] != nil {
MODEL = openai_cfg["MODEL"].(string)
} else {
log.Println("模型 未配置,使用默认 gpt-4o 模型")
MODEL = "gpt-4o"
}
if openai_cfg["PROMPT"] != nil {
PROMPT = openai_cfg["PROMPT"].(string)
} else {
log.Println("PROMPT 未配置,使用默认 PROMPT")
PROMPT = ""
}
if openai_cfg["Context"] != nil {
// var err error
context = openai_cfg["Context"].(int64)
// if err != nil {
// log.Println("Context 解析错误,使用默认 Context")
// context = 5
// }
} else {
log.Println("Context 未配置,使用默认 Context")
context = 5
}
return OPENAI_API_KEY, OPENAI_BaseURL, MODEL, PROMPT, int64(context)
}

103
tools/redisClient.go Normal file
View file

@ -0,0 +1,103 @@
package tools
import (
"context"
"fmt"
"go-bot/config"
"log"
"sync"
"github.com/go-redis/redis/v8"
)
var (
rdb *redis.Client
once sync.Once
ctx = context.Background()
)
// InitRedis 初始化 Redis 客户端(懒加载模式,确保只初始化一次)
func InitRedis() *redis.Client {
// 使用 sync.Once 确保客户端只初始化一次
once.Do(func() {
redisConfig := config.GetConfig()["REDIS"].(map[string]interface{})
rdb = redis.NewClient(&redis.Options{
Addr: redisConfig["REDIS_URL"].(string), // Redis 地址
Password: redisConfig["REDIS_PASSWORD"].(string), // Redis 密码,如果有设置
DB: int(redisConfig["REDIS_DB"].(int64)), // 使用的 Redis 数据库
PoolSize: 20, // 连接池大小
MinIdleConns: 10, // 最小空闲连接数
})
// 测试连接是否成功
_, err := rdb.Ping(ctx).Result()
if err != nil {
log.Println("Redis 连接失败:", err)
} else {
log.Println("Redis 连接成功")
}
})
return rdb
}
// GetRedisClient 获取 Redis 客户端实例
func GetRedisClient() *redis.Client {
if rdb == nil {
return InitRedis()
}
return rdb
}
// 添加对话到上下文超过5条则删除所有上下文
func AddToContext(key string, message string, contextlenth int64) {
// key := fmt.Sprintf("context:%s:%s:%s", worker, groupID, qqID)
// 如果上下文超过5条删除所有上下文
listLength := GetListLength(key)
log.Println("listLength:", listLength)
if listLength >= contextlenth {
rdb.Del(ctx, key) // 删除该用户的所有上下文
}
rdb.RPush(ctx, key, message) // 添加新消息到列表
}
// getListLength 获取列表长度
func GetListLength(key string) int64 {
return rdb.LLen(ctx, key).Val()
}
func GetListValue(key string, index int64) (string, error) {
return rdb.LIndex(ctx, key, index).Result()
}
// 删除列表最后一个元素
func RemoveLastValueFromList(key string) (string, error) {
// RPOP 命令会移除并返回列表的最后一个元素
value, err := rdb.RPop(ctx, key).Result()
if err != nil {
if err == redis.Nil {
return "", fmt.Errorf("列表为空或不存在")
}
return "", fmt.Errorf("删除最后一个值时出错: %v", err)
}
return value, nil
}
// SetValue 设置键值对
func SetValue(key string, value string) error {
return rdb.Set(ctx, key, value, 0).Err()
}
// GetValue 获取键对应的值
func GetValue(key string) (string, error) {
return rdb.Get(ctx, key).Result()
}
// CheckKeyExists 检查键是否存在
func CheckKeyExists(key string) (bool, error) {
val, err := rdb.Exists(ctx, key).Result()
if err != nil {
return false, err
}
return val > 0, nil
}

View file

@ -1 +0,0 @@
package utils

View file

@ -4,11 +4,12 @@ import (
"context"
"encoding/base64"
"fmt"
"go-bot/tools"
"io"
"log"
"net/http"
"os"
"log"
"regexp"
"strconv"
"strings"
@ -38,7 +39,7 @@ func (a *AI) GetMsg() string {
return "不问问题你说个屁!"
}
OPENAI_API_KEY, OPENAI_BaseURL, MODEL := getConfig()
OPENAI_API_KEY, OPENAI_BaseURL, MODEL, PROMPT, CONTEXT := tools.GetOAIConfig()
if OPENAI_API_KEY == "" {
return "OPENAI_API_KEY 未配置"
}
@ -48,34 +49,62 @@ func (a *AI) GetMsg() string {
client := openai.NewClientWithConfig(oaiconfig)
msg := fmt.Sprintf("[CQ:at,qq=%s] ", a.UID)
if strings.ToLower(a.Parms[1]) != "models" {
// OPENAI_BaseURL = OPENAI_BaseURL + "/chat/completions"
PROMPT, ok := cfg["PROMPT"].(string)
if !ok {
log.Println("PROMPT 未配置")
PROMPT = ""
}
// var requestBody map[string]interface{}
// 如果非回复消息
if !strings.HasPrefix(a.Parms[len(a.Parms)-1], "[CQ:reply,id=") {
messages := make([]openai.ChatCompletionMessage, 0)
messages = append(messages, openai.ChatCompletionMessage{
Role: openai.ChatMessageRoleSystem,
Content: PROMPT,
})
// println("messages0:", messages)
var key string
redisClient := tools.GetRedisClient()
// 如果redisClient不为空则获取上下文
if redisClient != nil {
key = fmt.Sprintf("context:%s:%s:%s", "ai", a.GID, a.UID)
// 获取上下文
tools.AddToContext(key, a.RawMsg[strings.Index(a.RawMsg, " ")+1:], CONTEXT)
// }
// println("RawMsg:", a.RawMsg[strings.Index(a.RawMsg, " ")+1:])
length := tools.GetListLength(key)
for i := 1; i < int(length); i++ {
message, err := tools.GetListValue(key, int64(i))
if err != nil {
log.Println("获取上下文失败:", err)
return "获取上下文失败"
}
messages = append(messages, openai.ChatCompletionMessage{
Role: openai.ChatMessageRoleUser,
Content: message,
})
}
} else {
messages = append(messages, openai.ChatCompletionMessage{
Role: openai.ChatMessageRoleUser,
Content: a.RawMsg[strings.Index(a.RawMsg, " ")+1:],
})
}
// println("messages:", messages)
// for i, msg := range messages {
// fmt.Printf("消息 %d:\n", i+1)
// fmt.Printf(" 角色: %s\n", msg.Role)
// fmt.Printf(" 内容: %s\n", msg.Content)
// fmt.Println()
// }
resp, err := client.CreateChatCompletion(
context.Background(),
openai.ChatCompletionRequest{
Model: MODEL,
Stream: false,
Messages: []openai.ChatCompletionMessage{
{
Role: "system",
Content: PROMPT,
},
{
Role: "user",
Content: a.RawMsg[strings.Index(a.RawMsg, " ")+1:],
},
},
Model: MODEL,
Stream: false,
Messages: messages,
},
)
if err != nil {
log.Println("ChatCompletion error: ", err)
tools.RemoveLastValueFromList(key)
return "请求失败"
}
// println(resp.Choices[0].Message.Content)
@ -170,7 +199,7 @@ func (a *AI) GetMsg() string {
var modelList string
for _, model := range models.Models {
if MODEL == model.ID {
model.ID = model.ID + "(当前使用)"
model.ID = model.ID + "\t(当前使用)"
}
modelList += model.ID + "\n"
}
@ -234,32 +263,6 @@ func stripMarkdown(text string) string {
return strings.TrimSpace(text)
}
func getConfig() (string, string, string) {
var OPENAI_API_KEY, OPENAI_BaseURL, MODEL string
if cfg["OPENAI_API_KEY"] != nil {
OPENAI_API_KEY = cfg["OPENAI_API_KEY"].(string)
} else {
log.Println("OPENAI_API_KEY 未配置")
}
if cfg["OPENAI_BaseURL"] != nil {
OPENAI_BaseURL = cfg["OPENAI_BaseURL"].(string)
} else {
log.Println("OPENAI_BaseURL 未配置,使用openai默认配置")
OPENAI_BaseURL = "https://api.openai.com/v1"
}
if cfg["MODEL"] != nil {
MODEL = cfg["MODEL"].(string)
} else {
log.Println("模型 未配置,使用默认 gpt-4o 模型")
MODEL = "gpt-4o"
}
return OPENAI_API_KEY, OPENAI_BaseURL, MODEL
}
func Image2Base64(path string, picUrl string) string {
// 尝试从文件路径读取图片
file, err := os.Open(path)