最近ChatGPT突然火起来了,实际上我从去年年底就一直在关注ChatGPT,最近我突发奇想,用ChatGPT搞了个QQ群机器人,并且实现了一些简单的功能。
注意:
本文有小部分内容由AI辅助创作,部分内容的真实性存疑,请自行判断。
本文会使用到一些方法绕过GFW,请在使用互联网时遵守当地的相关法律法规。

本文所介绍的Bot代码已经开源到了我的Github上。

很长一段时间没有写过新东西了,CPU也没在搞,主要是最近有点忙,其他事情比较多,所以就暂时没有花太多时间。不过实际上关于手搓CPU系列,我已经把16字节内存打出来了,甚至还自学了画板子,已经设计并制造出了KNArch的第一块板:时钟模块。甚至还已经完成了8位寄存器和ALU的加减法计算部分的电路板设计。

时钟模块的PCB板

不过今天的主角不是CPU,而是KnoteBot(下面简称bot)。

Github的机缘巧合

我订阅了一个Github的推送服务,这个服务每天早上九点左右会发送一个邮件到我的邮箱,邮件的内容是Github给我推荐的一些优秀的项目。Github推荐的项目一般来说会根据添加Star的项目来进行分析。

我其实一直有想基于ChatGPT做一个QQ群聊天机器人,但是一直只是想法,没有落实出来,直到有一天早晨,Github给我推了AmiyaBot的项目。本来就一直想做,Github又刚好给我推了一个现成的框架,那就没有理由再不做了。

不过这篇文章讨论的重点是对ChatGPT的使用,而不是QQ机器人的实现或者QQ的协议分析,所以有关Amiyabot的实现细节就不多阐述了,感兴趣的同学可以到上面的Github链接中深入了解。

少废话,先看东西

既然是介绍功能为主,那么先来看看具体能实现哪些功能,下面是由Markdown渲染而成的使用说明图(从Markdown到最终图片,均由bot处理):

Bot使用说明

目前来说,暂时还只有这么几个功能,不过这些功能还都挺实用的,下面对这些功能稍微介绍一下。

对于bot的操作,除了处于会话中的聊天以外,全部是按照shell命令的执行方法来设计的,正如上图最下面的介绍来说,任何命令加-h都可以获得其使用方法,比如我对#会话命令使用-h或--help,结果如下:

#会话 指令使用说明

从使用方法说明,我们就可以直到每个命令有哪些可使用的参数,每个参数的作用是什么。就比如根据上图来说,我们可以直到#会话命令目前一共有三个可选参数,第一个-h是输出使用方法,第二个-t或者--temperature是设置ChatGPT聊天的temperature值,第三个-so或者--system-order是设置ChatGPT的系统指令,这两个参数都是为设置ChatGPT而创建的,并且拥有默认值。

下面详细介绍以下使用说明图中的每个命令的用法和实现方法。

会话功能

聊天类指令中一共有四个指令,实际上这四个指令都是为了会话服务的,而这四个指令贯穿了从会话开始到结束的整个生命周期。使用#会话可以直接开启一段会话,此时就可以跟Bot谈笑风生了。

ChatGPT还是那么会扯淡

使用#结束会话指令来结束整段会话,并且Bot会将所有会话内容整合到一个聊天记录中:

使用这种方式可以方便保存和转发

另外两个指令,一个#重启会话其实就是方便重开一段会话,而不用先使用结束指令再使用开始指令这么麻烦,另一个会话历史则是在不结束会话的情况下,将所有已经进行的会话打包成类似上图的聊天记录的形式,这两条指令就不多介绍了。

下面简单介绍一下这些功能的实现方法。

上面这一系列功能都是用来实现ChatGPT对话的,其本质就是调用OpenAI给出的API接口。现阶段来说,OpenAI开放了GPT-3.5、text-davinci-002/003、code-davinci-002、以及DALL·E、Whisper等,其中GPT-3.5、text-davinci-002/003用于对话(Chat completion)及文本完善(Text completion),DALL·E用于图像生成(后面会用到),Whisper用于语言识别。还有需要申请才能使用的GPT-4,不过我暂时还没有见到过有人申请成功。

由于部分特殊原因,国内无法访问OpenAI的服务,所以我们需要使用一些手段绕过GFW(中国国家长城防火墙, Great Firewall)。我自己使用的方案是Trojan+clash,Torjan使用特征明显的TLS协议 (TLS/SSL),使得流量看起来与正常的HTTPS网站相同,所以其相较于ssr协议来说,有更高的安全性和隐匿性,其更难以被防火墙发现并查杀。而clash可以基于规则化的脚本对不同的流量进行多个代理服务器的分发。比如我同时在美国、香港、广州购买了三台VPS(我还真买了...不过不全是用于代理网络),我希望google和openai的服务从美国的VPS发出,普通的外网ip的访问流量从香港的VPS发出,国内的流量从广州的IP发出,那么我可以通过设置一个clash的规则文件,将所有google和openai的域名和ip的访问流量发送到美国的Torjan服务器,其他外网ip访问流量发送到香港的VPS,其余的走广州VPS。不过配置代理和网络并不是本文的主要内容,所以大家只需要知道,我的QQ机器人是部署在广州VPS上,并且我又在美国和香港的VPS上搭建了Torjan服务段即可。

OpenAI有官方的API接口,所以实际上直接import openai就可以很方便地调用其服务。但由于上述原因,我们在访问OpenAI的API时需要将HTTP/HTTPS的流量发送到127.0.0.1:7890(这是clash默认开发给局域网的http端口),所以我们无法直接使用openai的依赖包,而需要使用requests模块来手动发送请求。

Show me the code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
class ChatGPT:
    url = 'https://api.openai.com/v1/chat/completions'

    def __init__(self, temperature=0.7, system_order='', set_user=False):
        # 准确度,0到1之间,越小准确度越高,回答也就更精确,但限制更多
        self.temperature = temperature
        self.system_order = system_order
        # 是否在用户发送的对话前添加[username]
        self.set_user = set_user

        # 对话组,将所有对话保存在里面
        self.conversations_group = []
        if self.system_order:
            self.add_conversation('system', self.system_order)

        # 对话总token数
        self.tokens_count = 0
        self.next_alarm_token_counts = 30000

        # 会话开始时间
        self.start_time = time.time()

    def get_set_user(self):
        return self.set_user

    def get_start_time(self):
        return self.start_time

    def get_conversations_group(self):
        return self.conversations_group

    def get_conversations_count(self):
        """
        获取对话总次数
        :return:
        """

        return len(self.conversations_group)

    def get_tokens_count(self):
        return self.tokens_count

    def get_data(self):
        messages = [conversation['message'] for conversation in self.get_conversations_group()]
        return {
            "model": "gpt-3.5-turbo",
            "messages": messages,
            "temperature": self.temperature
        }

    def add_conversation(self, role, content):
        """
        添加对话到对话组
        :param role:
        :param content:
        :return:
        """

        self.conversations_group.append(self.gen_conversation(role, content))

    @staticmethod
    def gen_conversation(role, content):
        """
        通过角色和内容生成对话
        :param role:
        :param content:
        :return:
        """

        return {
            "message": {
                "role": role,
                "content": content,
            },
            # 下面是自己加的,不是ChatGPT要求的
            "send_time": time.time()
        }

    def tokens_usage_check(self):
        """
        检查token数,并在有需要时返回值进行报警
        :return:
        """

        if self.get_tokens_count() > self.next_alarm_token_counts:
            self.next_alarm_token_counts += 10000
            return self.get_tokens_count()
        return False

    def call(self, content):
        """
        将新的对话添加到对话组,并请求
        :param content:
        :return:
        """

        self.add_conversation('user', content)
        try:
            ret = requests.post(
                url=ChatGPT.url,
                headers=headers,
                data=json.dumps(self.get_data()),
                proxies=proxies
            ).text
            # print(ret)
            ret_json = json.loads(ret)
        except Exception as e:
            log.error(f"在处理ChatGPT对话时发生了错误: {e}")
            self.conversations_group.pop()
            return f"在处理ChatGPT对话时发生了错误: {e}"
        try:
            answer = ret_json['choices'][0]['message']['content']
            self.tokens_count += int(ret_json['usage']['total_tokens'])
        except Exception as e:
            log.error(f"在处理json时出现错误,{e},JSON原文为:{str(ret_json)}")
            self.add_conversation('assistant', 'no reply')
            return f"[ChatGPT Handler]在处理json时出现错误,{e},JSON原文为:{str(ret_json)}"
        self.add_conversation('assistant', answer)
        return answer


    def restart(self):
        """
        重新开始对话,但保留tokens_count计数
        :return:
        """

        self.conversations_group = []
        self.add_conversation('system', self.system_order)

我使用了一个类来实现GPT各模块的操作,这样每启动一个会话,只需要实例化ChatGPT类即可,下面来深入探索一下这个类中的方法。

首先是__init__,其中定义了一些类的变量,然后这个构造函数提供了3个参数,分别是temperature、system_order和set_user。前两个都很好理解,其对应了ChatGPT的这两个参数。最后一个set_user需要单独解释一下,起初我在群聊中部署了这个机器人,对话起来很流畅,没有什么问题,但使用了一段时间以后我发现,随机机器人在QQ群里面,但是GPT却无法分清楚每条消息是哪个群成员发送的,所以我设置了这个参数,当该参数为True时,这个类在发送群成员信息时,会在消息前面加上[群昵称],只要通过系统指令告诉GPT每条消息最前面的括号里面是说这句话的群成员,GPT就能认识并分清楚每个群成员的发言。

继续往下,是一些以get_开头的方法,这些方法都是用于获取类变量使用的,没有太多需要介绍的内容。

get_data是用于构造请求体data的方法,根据openai的官方文档,我们知道使用ChatGPT进行请求时,需要根据如下格式:

1
2
3
4
5
6
7
curl https://api.openai.com/v1/chat/completions \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -d '{
    "model": "gpt-3.5-turbo",
    "messages": [{"role": "user", "content": "Hello!"}]
  }'

每次向openai发送请求时,都将调用该方法,将ChatGPT对象中存储的对话内容格式化成文档中要求的格式。

这里提到了ChatGPT对象中存储的对话,详细的说:由于ChatGPT在每次进行请求时,需要提供上下文,并且按照规定的格式,所以我需要在对象中存储这一次对话的上下文,我使用了self.conversations_group用于存储对话组。而add_conversation就是用于向对象的对话组中插入对话的方法,这个方法需要两个参数,分别是角色(role)和内容(content)。在openai给出的文档当中,我们可以知道,ChatGPT接受三个角色,分别是系统(system)、用户(user)、助手(assistant),分别用于标记系统指令、用户和ChatGPT的对话内容。gen_conversation方法就是将self.conversations_group中存储的对话组转换成API要求的格式的内容。

为了方便计算费用,我设计了tokens_usage_check方法,该方法用于监控每轮对话使用的token数,一旦token数超过了阈值,则向用户发出提醒。

最后是call方法,需要提供一个参数,该参数为用户向ChatGPT发出的内容。在该方法内,程序会将用户希望发送的内容, 以及历史会话记录整合成指定的格式,并使用requests进行请求。

这个类的原理大概就是这样,不过实际上这个方法并不完美,Amiya机器人框架全程使用异步操作await/async,而requests请求是同步的,实际上为了更好的异步执行,应该选择异步请求类。不过我在初期调用了某个异步请求类,出现了一些不太好解决的问题,所以退而求其次使用了requests。

完成ChatGPT类代码的解析后,再来了解一下会话部分指令的处理方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
class Meta:
    command = "#会话"
    description = "开始一段会话,每个群只能同时开启一个单独的会话(除单次会话外)"

# 普通会话模式
async def start_session_verify(data: Message):
    return True if data.text.startswith(Meta.command) else False
@bot.on_message(verify=start_session_verify, level=normal_order_level, check_prefix=False)
async def start_session(data: Message):
    # 解析参数
    parser = ArgumentParser(prog=Meta.command, description=Meta.description, exit_on_error=False)

    # 添加选项和参数
    parser.add_argument('-t', '--temperature', type=float, default=0.7, help="ChatGPT的temperature值,为0-1之间的浮点数,该值越高(如0.8)将使输出更随机,而越低(例如0.2)将使其更集中和更确定,默认值0.7")
    parser.add_argument('-so', '--system-order', type=str, help="开始对话前的系统指令,用于指示该轮会话ChatGPT所扮演的角色或需要做的事,越详细越好,默认值为系统预置的普通对话系统指令")
    parser.add_argument('-no', '--no-order', action='store_true', help="是否不设置系统指令,如果指定该参数,则不设置任何系统指令(包括默认系统指令)")
    parser.add_argument('-s', '--single', action='store_true', help="是否为单句会话模式,如果指定该参数,则只进行一次问答,且不能设置系统指令(-so和--system-order参数无效)。")
    parser.add_argument('-p', '--prompt', type=str, default=None, help="对话提示词,在单句会话模式中使用,如果是单次会话模式(指定-s或者--single参数),则参数必须指定,否则指定该参数无效。")
    parser.add_argument('-u', '--user', action='store_true', help="是否在聊天前添加[username]来对用户进行区分,如果指定该参数,则添加用户名区分。在使用默认系统指令的连续会话时会自动指定,其他情况均不会自动指定。")

    # 解析命令
    try:
        args = parser.do_parse(data.text)
    except Exception as info:
        # 实际上不一定是错误,-h也会触发
        return Chain(data).text(info.__str__())

    temperature = args.temperature
    no = args.no_order
    single = args.single
    prompt = args.prompt
    user = args.user
    order = args.system_order
    if not single:
        if not no:
            if not args.system_order:
                order = system_order['普通对话']
                user = True
            else:
                order = args.system_order
        else:
            order = None

    # 先处理单句会话
    if single:
        if prompt is None:
            return Chain(data).text("使用单次会话模式时(指定-s或者--single参数),必须通过-p或者--prompt参数来指定提示词。\n详细使用方法请使用-h或--help参数查询。")
        return Chain(data).text(ChatGPT(temperature=temperature, system_order=None, set_user=user).call(content=(f"[{data.nickname}]" if user else '') + prompt))

    if gpt_sessions.get(data.channel_id, None) is not None:
        return Chain(data).text(f"上一次会话尚未结束,或结束后为及时清理会话,请使用"{end_session.Meta.command}"来清除当前会话,并使用"{Meta.command}"来开启一个新的ChatGPT会话。")

    gpt_sessions[data.channel_id] = ChatGPT(temperature=temperature, system_order=order, set_user=user)
    return Chain(data).text("\n\n新建ChatGPT会话,现在可以开始对话了。\n\n"
                            f"在会话过程中,如果你发送的内容不是想对{bot_name}说的,可以在发言内容前面加*,{bot_name}将不会看到这条消息\n\n"
                            f"[实验功能]在会话过程中,如果你想对{bot_name}说,但不想得到{bot_name}的回复,可以在发言内容前面加"-",{bot_name}可以看到这条消息,但不会回复。\n\n"
                            f"对话完成后,使用"{end_session.Meta.command}"指令来结束对话。")

这一段代码是#会话指令的实现,为了方便修改和生成指令文档,我给每个指令添加了一个Meta的类,该类标记了对应的指令和指令的描述,这一点我们在#使用说明指令的实现中再介绍。

其实代码的结构很简单,首先是对参数的处理,这里我继承了argparse.ArgumentParser,并按照我自己的需求进行了一些修改。因为实际上,argparse.ArgumentParser是用于shell执行时的命令解析,所以argparse.ArgumentParser在遇到一些错误时会直接退出程序,而该过程无法被异常捕捉。所以我重写了error方法,去掉了退出程序的部分,并将异常抛出交给外面一级的程序来解决,其实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class ArgumentParser(argparse.ArgumentParser):

    def do_parse(self, command):
        # 帮助信息
        parameters = shlex.split(command)[1:]
        if '-h' in parameters or '--help' in parameters:
            raise ThrowMessage(self.format_help())

        # 解析命令
        try:
            args = self.parse_args(args=parameters)
        except Exception as e:
            raise ThrowMessage(f"参数解析错误: {e}\n该命令使用方法如下:\n{self.format_help()}")

        return args

    def error(self, message):
        raise ThrowMessage(message)

其中ThrowMessage是我自定义的一个异常,该异常仅用于抛出消息交给上级异常处理程序处理,这里就不过多介绍了。

完成参数解析后,需要针对设置的参数进行判断,比如该次会话是否为单句会话,会话是否使用预设的系统指令,是否添加用户名区分之类的,这一段逻辑很简单,没什么需要单独介绍的。

不过需要说明的是,我在ChatGPT的类文件中定义了一个gpt_sessions = dict(),每个群如果创建了一个非单次会话,就会向这个字典中进行写入操作,方便下次处理时直接使用gpt_sessions[群号码]来取到该群的会话,并进行相关处理。

接下来是会话内容的处理,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
async def gpt_verify(data: Message):
    # 过滤掉以*开始的消息,作为停止词
    if data.text.startswith('*'):
        return None
    return True if gpt_sessions.get(data.channel_id, None) is not None else None
@bot.on_message(verify=gpt_verify, level=chat_level)
async def gpt(data: Message):
    if data.text.startswith("-"):
        gpt_sessions.get(data.channel_id).add_conversation('user', f"[{data.nickname}]{data.text[1:]}")
        return

    chat_gpt = gpt_sessions.get(data.channel_id)

    answer = chat_gpt.call((f"[{data.nickname}]" if chat_gpt.get_set_user() else '') + data.text)

    alarm = gpt_sessions.get(data.channel_id).tokens_usage_check()
    if alarm:
        await bot.send_message(Chain().text(f"[Alarm]当前会话已进行{gpt_sessions.get(data.channel_id).get_conversations_count()}次,Token已使用数量为: {alarm},请注意控制用量"), channel_id=data.channel_id)

    # 处理markdown
    if answer.startswith('[MD]'):
        return Chain(data).markdown(answer)
    else:
        return Chain(data).text(answer)

消息校验器中,我设置了几个判断逻辑,首先是消息是否以*开头,如果是则不处理,其次是从gpt_sessions中查询该群是否有正在进行的会话,如果没有则不处理。

在进行消息处理时,首先会判断该会话是否要求在提示词前面添加用户名区分,然后使用ChatGPT对象的call方法来获取GPT的回复。我在默认系统指令中告诉了GPTP:"如果某段话中含有markdown的内容,你需要在回答的最前面加上[MD]标识",所以如果使用默认系统指令,在GPT返回的消息中含有Markdown内容时,就会以Markdown图片的方式发送(因为QQ不支持发送格式化消息)。

其他的比如#结束会话等的指令实现起来都比较简单,就不过多介绍了。在结束会话时,将ChatGPT对象从gpt_sessions中删除即可。

搜索功能

在介绍画图功能之前,先来介绍更为简单的搜索功能,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
def get_middle_chars(s, n):
    middle = len(s) // 2
    start = middle - n // 2
    end = start + n
    return s[start:end]
def split_string(string, n):
    return [string[i:i+n] for i in range(0, len(string), n)]
class Meta:
    command = "#搜索"
    description = "通过搜索引擎搜索相关内容,并通过GPT进行资料整理。"
# 请求网站
async def search_verify(data: Message):
    return True if data.text.startswith(Meta.command) else None
@bot.on_message(verify=search_verify, level=normal_order_level, check_prefix=False)
async def search_website(data: Message):
    # 解析参数
    parser = ArgumentParser(prog=Meta.command, description=Meta.description, exit_on_error=False)
    # 添加选项和参数
    parser.add_argument('-k', '--keyword', type=str, default="kalinote.top", help="需要搜索的关键词,默认为"kalinote.top"")
    # 解析命令
    try:
        args = parser.do_parse(data.text)
    except Exception as info:
        # 实际上不一定是错误,-h也会触发
        return Chain(data).text(info.__str__())
    keyword = args.keyword
    url = f"https://www.google.com/search?q={keyword}"
    await bot.send_message(Chain().at(data.user_id).text("[实验功能]正在请求搜索内容,由于openai限制每分钟请求数,内容较多的页面所以可能会较慢,请稍等..."), channel_id=data.channel_id)
    async with async_playwright() as p:
        browser = await p.chromium.launch(
            proxy={
                "server": f"{PROXY_IP}:{PROXY_PORT}",
                "username": "",
                "password": ""
            }
        )
        page = await browser.new_page()
        await page.goto(url, wait_until="load")
        # 定位第一个 <h3> 标签的父标签并跳转
        # h3_element = await page.query_selector('h3')
        # parent_element = await h3_element.query_selector('xpath=..')
        # await page.goto(await parent_element.get_attribute('href'))
        # 处理机器人验证
        if await page.query_selector("#rc-anchor-container"):
            # print("执行了处理机器人验证")
            await page.click("#rc-anchor-container")
        h3_elements = await page.query_selector_all('h3')
        if not h3_elements:
            # 页面中没有h3元素
            await bot.send_message(Chain().text("出现错误,没有在google的搜索页面找到h3,请稍后或换一个关键词重试!"), channel_id=data.channel_id)
            # print(await page.content())
            screenshot_bytes = await page.screenshot(full_page=True)
            return Chain(data).image(screenshot_bytes)
        for h3_element in h3_elements:
            parent_element = await h3_element.query_selector("xpath=..")
            if not parent_element:
                continue
            href = await parent_element.get_attribute('href')
            if href is not None:
                # 找到具有href属性的父元素
                await page.goto(href)
                break
        screenshot_bytes = await page.screenshot(full_page=True)
        await bot.send_message(Chain().image(screenshot_bytes), channel_id=data.channel_id)
        # content = await page.inner_text('h1, p, div')
        full_content = await page.inner_text('body')
    content_list = split_string(full_content, 3000)
    count = 0
    for content in content_list:
        count += 1
        gpt = ChatGPT(temperature=0.3, system_order=system_order['网页分析助手'])
        await bot.send_message(Chain().text(f"第{count}/{len(content_list)}段:\n" + gpt.call(content)), channel_id=data.channel_id)
        time.sleep(1)
    return

该功能可以通过关键词在google上进行搜索,并且进入google搜索到的第一个页面,然后将页面的内容发送给ChatGPT进行整合,最后返回整合结果,其效果如下:

首先展示页面内容
然后将页面内容进行整合

不过由于GPT-3.5模型限制最大token为4096,所以实际上如果网页的内容超过4096tokens,就会分割成多个会话进行分析,且无法关联上下文内容。

为了更加安全、方便地取到页面,我没有直接使用requests请求或其他爬虫程序,而是使用playwright模拟浏览器进行请求,使用这个方法的好处是,该行为很难被服务器检测到,因为模拟浏览器与用户直接操作没有任何差异。并且可以直接通过浏览器渲染js,简单地解决了很多网站的反爬机制的问题。playwright微软开发的开源程序,所以关于更多playwright的信息,可以在playwright的GitHub网站上找到。

这个功能在页面文字较少且排版复杂的时候比较好用,但是如果页面文字较多,则会严重影响GPT进行内容分析。不过最新的GPT-4将最大tokens提升到了32k,所以等GPT-4公开且成本降低后,应该就可以解决这个问题。

画图

画图使用的是OpenAI的DALL·E模型,我编写了一个类来实现相关操作,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
class ImageGeneration:
    url = 'https://api.openai.com/v1/images/generations'

    def __init__(self, prompt, gen_number=1, size="1024x1024"):
        self.gen_number = gen_number
        self.size = size
        self.prompt = prompt
        self.urls = []

    def get_data(self):
        return {
            "prompt": self.prompt,
            "n": self.gen_number,
            "size": self.size
        }

    def call(self):
        try:
            ret = requests.post(
                url=ImageGeneration.url,
                headers=headers,
                data=json.dumps(self.get_data()),
                proxies=proxies
            ).text
            ret_json = json.loads(ret)
        except Exception as e:
            log.error(f"在请求图片时出现了错误: {e}")
            return f"在请求图片时出现了错误: {e}"
        try:
            urls = [datas['url'] for datas in ret_json['data']]
            self.urls = urls
        except Exception as e:
            log.error(f"在解析json时出现了错误: {e}, json原文: {ret_json}")
            return f"[Draw Handler]在处理json时出现错误,{e},JSON原文为:{str(ret_json)}"
        return urls

    def download_images(self):
        if not self.urls:
            return False
        filenames = []
        try:
            for url in self.urls:
                ret = requests.get(url=url, proxies=proxies)
                if ret.status_code != 200:
                    del ret
                    return False

                filename = str(hashlib.md5(ret.content).hexdigest()) + '.png'
                with open(f'{image_dir}/{filename}', 'wb') as f:
                    f.write(ret.content)
                filenames.append(filename)
            return filenames
        except Exception as e:
            log.error(f"在下载生成的图片时发生了错误: {e}")
            return False

实际上该类最主要的两个功能分别是call方法将提示词发送给服务器生成图片,然后是download_images方法,通过服务器返回的链接下载图片。

画图指令处理的基本流程就是,用户使用自然语言作为提示词,然后将提示词发送给ChatGPT让其改进为更适合DALL·E模型的提示词,最后生成图片,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
class Meta:
    command = "#画图"
    description = "通过自然语言绘制图片"
# 画图(先暂时固定生成1024,1张)
async def draw_verify(data: Message):
    return True if data.text.startswith(Meta.command) else None
@bot.on_message(verify=draw_verify, level=normal_order_level, check_prefix=False)
async def draw(data: Message):
    # 解析参数
    parser = ArgumentParser(prog=Meta.command, description=Meta.description, exit_on_error=False)
    # 添加选项和参数
    parser.add_argument('-n', '--generated-number', type=int, default=1, help="生成图片数量,一般为1-10之间的整数,不建议一次生成超过3张,数量过高出现问题的概率会增大,默认为1")
    parser.add_argument('-p', '--prompt', type=str, default=None, help="对需要生成的图片的描述,使用自然语言,越详细越好,虽然中文也可以,但是英文的准确度更高,此项必填")
    parser.add_argument('-s', '--size', type=str, default="512x512", help="生成图片的分辨率,只能为128x128、512x512、1024x1024其中之一,默认为512x512")
    # 解析命令
    try:
        args = parser.do_parse(data.text)
    except Exception as info:
        # 实际上不一定是错误,-h也会触发
        return Chain(data).text(info.__str__())
    number = args.generated_number
    prompt = args.prompt
    size = args.size
    # 判断参数是否正确
    if size not in ['128x128', '512x512', '1024x1024']:
        return Chain(data).text(f"指定的图像尺寸不正确,支持的尺寸为: 128x128、512x512、1024x1024,指定的尺寸为: {size}")
    if not prompt:
        return Chain(data).text(f"需要使用-p/--prompt参数指定prompt(对图片的描述),使用"{Meta.command} -h"查看详细帮助")
    if number > 10 or number < 1:
        return Chain(data).text(f"指定的数量不正确,数量只能为1-10之间的整数,指定的数量为: {number}")
    await bot.send_message(Chain().at(data.user_id).text("正在准备生成,请稍等..."), channel_id=data.channel_id)
    # 通过GPT将prompt处理为英文
    prompt_en = ChatGPT(temperature=0, system_order=system_order['翻译助手']).call(content=prompt)
    image_generation = ImageGeneration(prompt=prompt_en, gen_number=number, size=size)
    urls = image_generation.call()
    if type(urls) == str:
        return Chain(data).text(urls)
    files = image_generation.download_images()
    if not files:
        return Chain(data).text("图像生成错误,请稍后重试!")
    image_count = 0
    for file in files:
        with open(f'{image_dir}/{file}', 'rb') as f:
            image = f.read()
        image_count +=1
        await bot.send_message(Chain().text(f'prompt: {prompt_en}, size: {size}, number: {image_count}/{len(files)}').image(image), channel_id=data.channel_id)
    return

画图没有什么太多内容进行介绍,就只是简单地进行了一个接口调用,然后使用到了一些IO操作对图片进行处理,结合代码就能快速理解其逻辑。

其效果如下:

图片生成效果

不过很可惜的是,OpenAI对图片的提示词进行了非常多的限制,所以有条件的话,最好可以在本地搭建stable diffusion,该平台有非常多优秀的模型。

最后附上一张文心一言的名场面复刻:

mouse and bus?

总结与展望

还有一些比较简单的功能就不再依次介绍了,总之,这个机器人虽然在OpenAI的加持下,已经非常有趣,但是仍然有很多功能优化不够到位。

在这个充满变革的时代,人工智能技术已经成为我们生活中的一部分,它正在逐步改变我们的世界。从医疗到教育,从生产到服务,AI的影响力愈发显著。通过对大量数据的学习和分析,智能系统为我们提供了高效、便捷的解决方案,使我们的生活更加美好。

未来的人类社会将进一步拥抱AI技术,许多传统行业将被重新定义,工作效率和生产力将得到前所未有的提升。而随着智能系统的不断优化,我们将更加重视人类情感、创造力和社会责任感等方面的发展。AI的推动下,我们的生活方式将愈发多元化,资源配置将更加合理,从而解决诸多全球性难题。

总之,人工智能技术的发展为我们的生活带来了广阔的前景,让我们充满期待。正如乔布斯所言:“我们生活的时代是如此神奇,我们必须抓住这个机会。”让我们携手共进,迎接AI技术为人类带来的更加美好的未来。

这个结尾其实是GPT-4编写的,顺便说一句,乔布斯似乎并没有说过这样的话...


学而不思则罔,思而不学则殆