feat(aiimage&aichat): add new plugin & summary of group chat (#1187)

This commit is contained in:
himawari
2025-08-22 22:37:35 +08:00
committed by GitHub
parent 2fa7868838
commit 34f3b9ba2a
9 changed files with 379 additions and 61 deletions

View File

@@ -1,14 +1,16 @@
// Package aichat OpenAI聊天
// Package aichat OpenAI聊天和群聊总结
package aichat
import (
"math/rand"
"strconv"
"strings"
"time"
"github.com/fumiama/deepinfra"
"github.com/fumiama/deepinfra/model"
"github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
zero "github.com/wdvxdr1123/ZeroBot"
"github.com/wdvxdr1123/ZeroBot/message"
@@ -18,6 +20,7 @@ import (
ctrl "github.com/FloatTech/zbpctrl"
"github.com/FloatTech/zbputils/chat"
"github.com/FloatTech/zbputils/control"
"github.com/FloatTech/zbputils/ctxext"
)
var (
@@ -30,9 +33,9 @@ var (
"- 设置AI聊天温度80\n" +
"- 设置AI聊天接口类型[OpenAI|OLLaMA|GenAI]\n" +
"- 设置AI聊天(不)支持系统提示词\n" +
"- 设置AI聊天接口地址https://api.deepseek.com/chat/completions\n" +
"- 设置AI聊天接口地址https://api.siliconflow.cn/v1/chat/completions\n" +
"- 设置AI聊天密钥xxx\n" +
"- 设置AI聊天模型名xxx\n" +
"- 设置AI聊天模型名Qwen/Qwen3-8B\n" +
"- 查看AI聊天系统提示词\n" +
"- 重置AI聊天系统提示词\n" +
"- 设置AI聊天系统提示词xxx\n" +
@@ -41,7 +44,9 @@ var (
"- 设置AI聊天最大长度4096\n" +
"- 设置AI聊天TopP 0.9\n" +
"- 设置AI聊天(不)以AI语音输出\n" +
"- 查看AI聊天配置\n",
"- 查看AI聊天配置\n" +
"- 重置AI聊天\n" +
"- 群聊总结 [消息数目]|群聊总结 1000\n",
PrivateDataFolder: "aichat",
})
)
@@ -53,6 +58,7 @@ var (
"GenAI": 2,
}
apilist = [3]string{"OpenAI", "OLLaMA", "GenAI"}
limit = ctxext.NewLimiterManager(time.Second*30, 1)
)
func init() {
@@ -305,4 +311,93 @@ func init() {
}
ctx.SendChain(message.Text(printConfig(rate, temp, cfg)))
})
en.OnFullMatch("重置AI聊天", ensureconfig, zero.OnlyPrivate, zero.SuperUserPermission).SetBlock(true).Handle(func(ctx *zero.Ctx) {
chat.Reset()
ctx.SendChain(message.Text("成功"))
})
// 添加群聊总结功能
en.OnRegex(`^群聊总结\s?(\d*)$`, ensureconfig, zero.OnlyGroup, zero.AdminPermission).SetBlock(true).Limit(limit.LimitByGroup).Handle(func(ctx *zero.Ctx) {
ctx.SendChain(message.Text("少女思考中..."))
p, _ := strconv.ParseInt(ctx.State["regex_matched"].([]string)[1], 10, 64)
if p > 1000 {
p = 1000
}
if p == 0 {
p = 200
}
gid := ctx.Event.GroupID
group := ctx.GetGroupInfo(gid, false)
if group.MemberCount == 0 {
ctx.SendChain(message.Text(zero.BotConfig.NickName[0], "未加入", group.Name, "(", gid, "),无法获取摘要"))
return
}
var messages []string
h := ctx.GetGroupMessageHistory(gid, 0, p, false)
h.Get("messages").ForEach(func(_, msgObj gjson.Result) bool {
nickname := msgObj.Get("sender.nickname").Str
text := strings.TrimSpace(message.ParseMessageFromString(msgObj.Get("raw_message").Str).ExtractPlainText())
if text != "" {
messages = append(messages, nickname+": "+text)
}
return true
})
if len(messages) == 0 {
ctx.SendChain(message.Text("ERROR: 历史消息为空或者无法获得历史消息"))
return
}
// 调用大模型API进行摘要
summary, err := summarizeMessages(messages)
if err != nil {
ctx.SendChain(message.Text("ERROR: ", err))
return
}
var b strings.Builder
b.WriteString("群 ")
b.WriteString(group.Name)
b.WriteByte('(')
b.WriteString(strconv.FormatInt(gid, 10))
b.WriteString(") 的 ")
b.WriteString(strconv.FormatInt(p, 10))
b.WriteString(" 条消息总结:\n\n")
b.WriteString(summary)
// 分割总结内容为多段
parts := strings.Split(b.String(), "\n\n")
msg := make(message.Message, 0, len(parts))
for _, part := range parts {
if part != "" {
msg = append(msg, ctxext.FakeSenderForwardNode(ctx, message.Text(part)))
}
}
if len(msg) > 0 {
ctx.Send(msg)
}
})
}
// summarizeMessages 调用大模型API进行消息摘要
func summarizeMessages(messages []string) (string, error) {
// 使用现有的AI配置进行摘要
x := deepinfra.NewAPI(cfg.API, cfg.Key)
mod := model.NewOpenAI(
cfg.ModelName, cfg.Separator,
float32(70)/100, 0.9, 4096,
)
// 构造摘要请求提示
summaryPrompt := "请总结这个群聊内容,要求按发言顺序梳理,明确标注每个发言者的昵称,并完整呈现其核心观点、提出的问题、发表的看法或做出的回应,确保不遗漏关键信息,且能体现成员间的对话逻辑和互动关系:\n\n" +
strings.Join(messages, "\n---\n")
data, err := x.Request(mod.User(summaryPrompt))
if err != nil {
return "", err
}
return strings.TrimSpace(data), nil
}

56
plugin/aiimage/config.go Normal file
View File

@@ -0,0 +1,56 @@
// Package aiimage 提供AI画图功能配置
package aiimage
import (
"fmt"
"strings"
"sync"
sql "github.com/FloatTech/sqlite"
)
// storage 管理画图配置存储
type storage struct {
sync.RWMutex
db sql.Sqlite
}
// imageConfig 存储AI画图配置信息
type imageConfig struct {
ID int64 `db:"id"` // 主键ID
APIKey string `db:"apiKey"` // API密钥
APIURL string `db:"apiUrl"` // API地址
ModelName string `db:"modelName"` // 画图模型名称
}
// getConfig 获取当前配置
func (sdb *storage) getConfig() imageConfig {
sdb.RLock()
defer sdb.RUnlock()
cfg := imageConfig{}
_ = sdb.db.Find("config", &cfg, "WHERE id = 1")
return cfg
}
// setConfig 设置AI画图配置
func (sdb *storage) setConfig(apiKey, apiURL, modelName string) error {
sdb.Lock()
defer sdb.Unlock()
return sdb.db.Insert("config", &imageConfig{
ID: 1,
APIKey: apiKey,
APIURL: apiURL,
ModelName: modelName,
})
}
// PrintConfig 返回格式化后的配置信息
func (sdb *storage) PrintConfig() string {
cfg := sdb.getConfig()
var builder strings.Builder
builder.WriteString("当前AI画图配置:\n")
builder.WriteString(fmt.Sprintf("• 密钥: %s\n", cfg.APIKey))
builder.WriteString(fmt.Sprintf("• 接口地址: %s\n", cfg.APIURL))
builder.WriteString(fmt.Sprintf("• 模型名: %s\n", cfg.ModelName))
return builder.String()
}

171
plugin/aiimage/main.go Normal file
View File

@@ -0,0 +1,171 @@
// Package aiimage AI画图
package aiimage
import (
"bytes"
"encoding/json"
"net/http"
"strings"
"time"
fcext "github.com/FloatTech/floatbox/ctxext"
"github.com/FloatTech/floatbox/web"
sql "github.com/FloatTech/sqlite"
"github.com/tidwall/gjson"
zero "github.com/wdvxdr1123/ZeroBot"
"github.com/wdvxdr1123/ZeroBot/message"
ctrl "github.com/FloatTech/zbpctrl"
"github.com/FloatTech/zbputils/control"
"github.com/FloatTech/zbputils/ctxext"
)
func init() {
var sdb = &storage{}
en := control.AutoRegister(&ctrl.Options[*zero.Ctx]{
DisableOnDefault: false,
Extra: control.ExtraFromString("aiimage"),
Brief: "AI画图",
Help: "- 设置AI画图密钥xxx\n" +
"- 设置AI画图接口地址https://api.siliconflow.cn/v1/images/generations\n" +
"- 设置AI画图模型名Kwai-Kolors/Kolors\n" +
"- 查看AI画图配置\n" +
"- AI画图 [描述]",
PrivateDataFolder: "aiimage",
})
getdb := fcext.DoOnceOnSuccess(func(ctx *zero.Ctx) bool {
sdb.db = sql.New(en.DataFolder() + "aiimage.db")
err := sdb.db.Open(time.Hour)
if err == nil {
// 创建配置表
err = sdb.db.Create("config", &imageConfig{})
if err != nil {
ctx.SendChain(message.Text("[ERROR]:", err))
return false
}
return true
}
ctx.SendChain(message.Text("[ERROR]:", err))
return false
})
en.OnPrefix("设置AI画图密钥", getdb, zero.OnlyPrivate, zero.SuperUserPermission).SetBlock(true).
Handle(func(ctx *zero.Ctx) {
apiKey := strings.TrimSpace(ctx.State["args"].(string))
cfg := sdb.getConfig()
err := sdb.setConfig(apiKey, cfg.APIURL, cfg.ModelName)
if err != nil {
ctx.SendChain(message.Text("ERROR: 设置API密钥失败: ", err))
return
}
ctx.SendChain(message.Text("成功设置API密钥"))
})
en.OnPrefix("设置AI画图接口地址", getdb, zero.OnlyPrivate, zero.SuperUserPermission).SetBlock(true).
Handle(func(ctx *zero.Ctx) {
apiURL := strings.TrimSpace(ctx.State["args"].(string))
cfg := sdb.getConfig()
err := sdb.setConfig(cfg.APIKey, apiURL, cfg.ModelName)
if err != nil {
ctx.SendChain(message.Text("ERROR: 设置API地址失败: ", err))
return
}
ctx.SendChain(message.Text("成功设置API地址"))
})
en.OnPrefix("设置AI画图模型名", getdb, zero.OnlyPrivate, zero.SuperUserPermission).SetBlock(true).
Handle(func(ctx *zero.Ctx) {
modelName := strings.TrimSpace(ctx.State["args"].(string))
cfg := sdb.getConfig()
err := sdb.setConfig(cfg.APIKey, cfg.APIURL, modelName)
if err != nil {
ctx.SendChain(message.Text("ERROR: 设置模型失败: ", err))
return
}
ctx.SendChain(message.Text("成功设置模型: ", modelName))
})
en.OnFullMatch("查看AI画图配置", getdb, zero.OnlyPrivate, zero.SuperUserPermission).SetBlock(true).
Handle(func(ctx *zero.Ctx) {
ctx.SendChain(message.Text(sdb.PrintConfig()))
})
en.OnPrefix("AI画图", getdb).SetBlock(true).
Handle(func(ctx *zero.Ctx) {
ctx.SendChain(message.Text("少女思考中..."))
prompt := strings.TrimSpace(ctx.State["args"].(string))
if prompt == "" {
ctx.SendChain(message.Text("请输入图片描述"))
return
}
cfg := sdb.getConfig()
if cfg.APIKey == "" || cfg.APIURL == "" || cfg.ModelName == "" {
ctx.SendChain(message.Text("请先配置API密钥、地址和模型"))
return
}
// 准备请求数据
reqBytes, _ := json.Marshal(map[string]interface{}{
"model": cfg.ModelName,
"prompt": prompt,
"image_size": "1024x1024",
"batch_size": 4,
"num_inference_steps": 20,
"guidance_scale": 7.5,
})
// 发送API请求
data, err := web.RequestDataWithHeaders(
web.NewDefaultClient(),
cfg.APIURL,
"POST",
func(req *http.Request) error {
req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
req.Header.Set("Content-Type", "application/json")
return nil
},
bytes.NewReader(reqBytes),
)
if err != nil {
ctx.SendChain(message.Text("API请求失败: ", err))
return
}
// 解析API响应
jsonData := gjson.ParseBytes(data)
images := jsonData.Get("images")
if !images.Exists() {
images = jsonData.Get("data")
if !images.Exists() {
ctx.SendChain(message.Text("未获取到图片URL"))
return
}
}
// 发送生成的图片和相关信息
inferenceTime := jsonData.Get("timings.inference").Float()
seed := jsonData.Get("seed").Int()
msg := make(message.Message, 0, 1)
msg = append(msg, ctxext.FakeSenderForwardNode(ctx, message.Text("图片生成成功!\n",
"提示词: ", prompt, "\n",
"模型: ", cfg.ModelName, "\n",
"推理时间: ", inferenceTime, "秒\n",
"种子: ", seed)))
// 添加所有图片
images.ForEach(func(_, value gjson.Result) bool {
url := value.Get("url").String()
if url != "" {
msg = append(msg, ctxext.FakeSenderForwardNode(ctx, message.Image(url)))
}
return true
})
if len(msg) > 0 {
ctx.Send(msg)
}
})
}

View File

@@ -43,8 +43,15 @@ func init() {
})
engine.OnPrefix(`查询水群`, zero.OnlyGroup).SetBlock(true).Handle(func(ctx *zero.Ctx) {
param := ctx.State["args"].(string)
var uid int64
if len(ctx.Event.Message) > 1 && ctx.Event.Message[1].Type == "at" {
uid, _ = strconv.ParseInt(ctx.Event.Message[1].Data["qq"], 10, 64)
} else if param == "" {
uid = ctx.Event.UserID
}
name := ctx.NickName()
todayTime, todayMessage, totalTime, totalMessage := ctdb.getChatTime(ctx.Event.GroupID, ctx.Event.UserID)
todayTime, todayMessage, totalTime, totalMessage := ctdb.getChatTime(ctx.Event.GroupID, uid)
ctx.SendChain(message.Reply(ctx.Event.MessageID), message.Text(fmt.Sprintf("%s今天水了%d分%d秒发了%d条消息总计水了%d分%d秒发了%d条消息。", name, todayTime/60, todayTime%60, todayMessage, totalTime/60, totalTime%60, totalMessage)))
})
engine.OnFullMatch("查看水群排名", zero.OnlyGroup).Limit(ctxext.LimitByGroup).SetBlock(true).

View File

@@ -8,7 +8,6 @@ import (
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/go-ego/gse"
@@ -40,7 +39,7 @@ func init() {
engine := control.AutoRegister(&ctrl.Options[*zero.Ctx]{
DisableOnDefault: false,
Brief: "聊天热词",
Help: "- 热词 [群号] [消息数目]|热词 123456 1000",
Help: "- 热词 [消息数目]|热词 1000",
PublicDataFolder: "WordCount",
})
cachePath := engine.DataFolder() + "cache/"
@@ -51,7 +50,7 @@ func init() {
}
_ = os.RemoveAll(cachePath)
_ = os.MkdirAll(cachePath, 0755)
engine.OnRegex(`^热词\s?(\d*)\s?(\d*)$`, zero.OnlyGroup, fcext.DoOnceOnSuccess(func(ctx *zero.Ctx) bool {
engine.OnRegex(`^热词\s?(\d*)$`, zero.OnlyGroup, fcext.DoOnceOnSuccess(func(ctx *zero.Ctx) bool {
_, err := engine.GetLazyData("stopwords.txt", false)
if err != nil {
ctx.SendChain(message.Text("ERROR: ", err))
@@ -85,17 +84,14 @@ func init() {
}
ctx.SendChain(message.Text("少女祈祷中..."))
gid, _ := strconv.ParseInt(ctx.State["regex_matched"].([]string)[1], 10, 64)
p, _ := strconv.ParseInt(ctx.State["regex_matched"].([]string)[2], 10, 64)
p, _ := strconv.ParseInt(ctx.State["regex_matched"].([]string)[1], 10, 64)
if p > 10000 {
p = 10000
}
if p == 0 {
p = 1000
}
if gid == 0 {
gid = ctx.Event.GroupID
}
gid := ctx.Event.GroupID
group := ctx.GetGroupInfo(gid, false)
if group.MemberCount == 0 {
ctx.SendChain(message.Text(zero.BotConfig.NickName[0], "未加入", group.Name, "(", gid, "),无法获得热词呢"))
@@ -108,44 +104,22 @@ func init() {
return
}
messageMap := make(map[string]int, 256)
msghists := make(chan *gjson.Result, 256)
go func() {
h := ctx.GetLatestGroupMessageHistory(gid)
messageSeq := h.Get("messages.0.message_seq").Int()
msghists <- &h
for i := 1; i < int(p/20) && messageSeq != 0; i++ {
h := ctx.GetGroupMessageHistory(gid, messageSeq)
msghists <- &h
messageSeq = h.Get("messages.0.message_seq").Int()
}
close(msghists)
}()
var wg sync.WaitGroup
var mapmu sync.Mutex
for h := range msghists {
wg.Add(1)
go func(h *gjson.Result) {
for _, v := range h.Get("messages.#.message").Array() {
tex := strings.TrimSpace(message.ParseMessageFromString(v.Str).ExtractPlainText())
if tex == "" {
continue
}
segments := seg.Segment(helper.StringToBytes(tex))
words := gse.ToSlice(segments, true)
for _, word := range words {
word = strings.TrimSpace(word)
i := sort.SearchStrings(stopwords, word)
if re.MatchString(word) && (i >= len(stopwords) || stopwords[i] != word) {
mapmu.Lock()
messageMap[word]++
mapmu.Unlock()
}
h := ctx.GetGroupMessageHistory(gid, 0, p, false)
h.Get("messages").ForEach(func(_, msgObj gjson.Result) bool {
tex := strings.TrimSpace(message.ParseMessageFromString(msgObj.Get("raw_message").Str).ExtractPlainText())
if tex != "" {
segments := seg.Segment(helper.StringToBytes(tex))
words := gse.ToSlice(segments, true)
for _, word := range words {
word = strings.TrimSpace(word)
i := sort.SearchStrings(stopwords, word)
if re.MatchString(word) && (i >= len(stopwords) || stopwords[i] != word) {
messageMap[word]++
}
}
wg.Done()
}(h)
}
wg.Wait()
}
return true
})
wc := rankByWordCount(messageMap)
if len(wc) > 20 {