Press "Enter" to skip to content

基于 OpenAI API + Laravel 快速构建网页版 ChatGPT

上篇教程中我们提到,OpenAI 近期发布了全新的 ChatGPT API,并且在该教程中,学院君给大家演示了如何基于这个 API 快速实现命令行版 ChatGPT,今天,让我们看看如何利用它结合 Laravel 10 构建 ChatGPT 网页版,我们把这个网页克隆版的 ChatGPT 命名为 GeekChat

如果您想直接跳转到源代码,可以在我的 GitHub 上找到它。

这个新的 API 模型提示方式和之前有点不同,因为它针对的是“聊天”自动完成,所以我们不仅仅是发送一个简单的字符串作为提示,而是发送一整段聊天对话,然后 AI 模型将自动完成对话功能。

下面是我们今天要完成的 ChatGPT 网页克隆版 GeekChat 的最终样子,比较简陋,但该有的核心功能也都具备了:

整体功能并不复杂,这里我们使用网页应用开发神器 Laravel 框架结合 Tailwind CSS 快速完成应用的开发。

PS:经过数次迭代,这个项目最新面孔已经是这样的了,成为一个支持文字、语音、翻译、画图的多功能聊天机器人,你可以通过这个链接进行体验 —— https://wen.geekr.dev

初始化项目

作为起点,我们使用 Laravel 安装器初始化一个新的 Laravel 10 应用程序:

laravel new geekchat

不了解 Laravel 框架的可以看下官方文档

然后通过 Composer 安装 OpenAI PHP 扩展包,该扩展包可用于在 PHP 项目中调用 OpenAI API 接口:

composer require geekr/openai-laravel

接下来,我们需要发布上面这个扩展包的配置文件并设置 OpenAI API 密钥。

要发布配置文件,运行以下命令即可:

php artisan vendor:publish --provider="GeekrOpenAI\Laravel\ServiceProvider"

发布完成后,可以在 .env 文件中设置 OpenAI API 密钥,如下所示:

OPENAI_API_KEY="你的 OpenAI 密钥"

此外,这个扩展包还支持代理配置,已解决国内调用不了 OpenAI 接口的问题:

OPENAI_BASE_URI=open.aiproxy.xyz

构建会话表单

我们想要的是一个聊天样式的 UI,所以需要在默认首页视图文件 welcome.blade.php 中添加一个简单的问题输入字段以及一个“重置会话”按钮,就像我们在 ChatGPT 中重置当前会话一样:

image-20230305184121357

这个输入字段需要放在一个表单中,该表单用于将输入的问题提交到 Laravel 应用程序的 POST 路由 /chat,从而完成与 ChatGPT 的会话:

<form class="p-4 flex space-x-4 justify-center items-center" action="/chat" method="post">
        @csrf
        <input id="message" placeholder="输入你的问题..." type="text" name="message" autocomplete="off" class="border rounded-md  p-2 flex-1" required />
        <button class="flex items-center justify-center px-4 py-2 bg-green-500 hover:bg-green-600 text-white rounded-md text-sm md:text-base" type="submit">
            <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
                <path stroke-linecap="round" stroke-linejoin="round" d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5" />
            </svg>
        </button>
        <button class="flex items-center justify-center px-4 py-2 bg-gray-400 hover:bg-gray-500 text-white rounded-md text-sm md:text-base" onclick="window.location.href='/reset'">
            <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
                <path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
            </svg>
        </button>
    </form>

接下来,我们将完成后端处理会话与重置会话的核心功能。

实现会话核心功能

我们将新建一个控制器 ChatController 处理会话相关的业务逻辑:

image-20230305185940894

然后在控制器中定义会话处理方法,核心逻辑其实和上篇命令行版 ChatGPT 大同小异,只是换了一种语言实现而已:

<?php

namespace App\Http\Controllers;

use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use GeekrOpenAI\Laravel\Facades\OpenAI;

class ChatController extends Controller
{
    /**
     * Handle the incoming prompt.
     */
    public function chat(Request $request): RedirectResponse
    {
        // 系统消息
        $messages = $request->session()->get('messages', [
            ['role' => 'system', 'content' => 'You are GeekChat - A ChatGPT clone. Answer as concisely as possible.']
        ]);

        // 用户消息
        $messages[] = ['role' => 'user', 'content' => $request->input('message')];

        $response = OpenAI::chat()->create([
            'model' => 'gpt-3.5-turbo',
            'messages' => $messages
        ]);

        // 响应消息
        $messages[] = ['role' => 'assistant', 'content' => $response->choices[0]->message->content];

        $request->session()->put('messages', $messages);

        return redirect('/');
    }
}

正如我上面提到的,此处的提示有点不同 —— 它是来自用户的消息和从 OpenAI 获得的响应的混合物。新的聊天 API 还允许我们定义“系统”消息,这是某种通用指令,用于告诉聊天模型其一般用途应该是什么。

这里我们通过 $messages 数组聚合提示,作为默认值,我们将在其中放置我们的“系统”消息,然后将用户问题消息放进来,就可以使用这个数组并执行 API 请求:

$response = OpenAI::chat()->create([
    'model' => 'gpt-3.5-turbo',
    'messages' => $messages
]);

拿到 OpenAI 聊天响应消息后,我们还要将其添加到我们的 $messages 数组中:

$messages[] = ['role' => 'assistant', 'content' => $response->choices[0]->message->content];

请注意,这里我们添加来自 API 响应消息时,使用了 “assistant” 角色将其添加到 $messages 数组中,以表明这是来自 API 而不是用户的消息。

这样一来,我们就能够提出涉及先前消息和回复上下文的问题了。

由于这些“消息”需要随时间增长,我们需要将它们存储在某个地方,对于这个简单的克隆版本,我们将把消息存储在会话中。现在我们的 $messages 数组包含了我们需要的所有消息,我们可以将其存储回会话中并重定向回去:

$request->session()->put('messages', $messages);

return redirect('/');

就是这样!下一次用户发送消息时,我们将重用会话中的消息并将新消息附加到其中,就像 ChatGPT 一样。

最后不要忘了在路由文件 routes/web.php 中定义路由与控制器方法的映射关系:

Route::post('/chat', ChatController::class . '@chat');

相比之下,重置会话就简单多了,只需要清空会话中的消息数据,然后重定向到首页即可,还是在 ChatController 中定义重置会话方法:

/**
 * Reset the session.
 */
public function reset(Request $request): RedirectResponse
{
    $request->session()->forget('messages');

    return redirect('/');
}

对应的路由映射关系如下:

Route::get('/reset', ChatController::class . '@reset');

完成前端展示

现在,我们在会话中有了所有的消息(包含来自 OpenAI 响应和用户的消息),我们需要做的就是将它们传递给视图并向用户显示它们。这一切都是在首页路由中完成的。

为了不显示内部的“系统”消息,我们可以在将消息传递给视图之前从消息数组中删除它:

Route::get('/', function () {
    $messages = collect(session('messages', []))->reject(fn ($message) => $message['role'] === 'system');

    return view('welcome', [
        'messages' => $messages
    ]);
});

在视图中,我现在只是循环遍历消息,根据其来自用户还是来自 GeekChat 给它们不同的背景颜色,然后使用 Markdown 解析器解析消息内容并渲染:

@foreach($messages as $message)
    <div class="flex rounded-lg p-4 @if ($message['role'] === 'assistant') bg-green-200 flex-reverse @else bg-blue-200 @endif ">
        <div class="ml-4">
            <div class="text-lg">
                @if ($message['role'] === 'assistant')
                    <a href="#" class="font-medium text-gray-900">GeekChat</a>
                @else
                    <a href="#" class="font-medium text-gray-900">你</a>
                @endif
            </div>
            <div class="mt-1">
                <p class="text-gray-600">
                    {!! \Illuminate\Mail\Markdown::parse($message['content']) !!}
                </p>
            </div>
        </div>
    </div>
@endforeach

这就是所有需要做的事情,得益于新的 OpenAI ChatGPT API,你可以轻松构建自己的 ChatGPT 克隆版。

基于 Docker 开发部署

如果你对 Laravel 部署,还可以使用 Laravel 自带的 Sail 扩展包通过 Docker 在本地部署启动应用,开始之前需要先安装 Sail 扩展包:

composer require laravel/sail --dev

由于这个项目比较简单,没有数据库、缓存、消息队列,所以我们直接通过 Sail 启动应用就好了(确保此时 Docker Desktop 已经启动并运行):

./vendor/bin/sail up -d

如果启动过程中出现类似下面这样的错误:

#0 274.4 tee: /etc/apt/keyrings/ppa_ondrej_php.gpg: No such file or directory

多半是网络原因导致的,可以参照这篇教程设置 Ubuntu 软件源的国内镜像,或者参考这个项目的 Github 代码仓库,我把很多不需要数据库、前端依赖都删除掉了,同时移除了本地对 PHP/Composer/Sail 的依赖,保留最小可用资源,直接通过 docker-compose up -d 启动即可。

启动成功后,就可以通过 http://localhost在浏览器中访问 ChatGPT 网页版了:

如果是部署到生产环境,可以使用 php-fpm 作为 http 服务器,也可以使用 Octane 扩展包提供的高性能 http 服务器选项 —— Swoole 或者 RoadRunner,我这个 GeekChat 是基于 Docker + RoadRunner 部署的。

最后,我们用以下对话结束今天的教程:

image-20230305225252997

如果你没有 ChatGPT 账号,想要快速体验,可以访问这个演示版:https://wen.geekr.dev,演示版背后的源码就是今天这篇教程演示的。

附录:国内无法调用 OpenAI 接口的解决办法

设置 HTTP 代理

不少同学反映国内无法直接通过代码调用 OpenAI 接口,我在写这个示例项目的时候也遇到这个问题,解决办法也不难,就是在发起 HTTP 请求的时候在请求头中添加代理设置:

'proxy' => 'http://127.0.0.1:10809',
'verify' => false,

如果是通过 curl 发起的请求也是参照这个思路。Go HTTP 代理设置参考这个代码配置

之前使用的 `openai-php/laravel` 这个扩展包不支持对代理进行设置,也不支持对 ClientHeaders 进行扩展(都是通过 final 修饰):

image-20230306103023031

因此我重新开发了一个扩展包,也就是今天项目中使用的 geekr/openai-laravel,主要就是在原来的基础上支持配置代理(以域名代理的方式实现,不是这种配置本地代理,本地代理只能本地使用)。

此外,你还可以使用另一个 OpenAI PHP 扩展包替代 —— orhanerday/open-ai,该扩展包支持你对代理进行设置:

image-20230306105746903

通过中间层代理

不过如果你没有本地代理或者不想每个项目配置,还可以使用 Cloudflare Workers 解决 OpenAI 和 ChatGPT 的 API 无法访问的问题,其实就是把一个国内可访问的域名指向 Cloudflare Workers,再将 Cloudflare Workers 作为代理,转发给 OpenAI 接口进行交互,最后把响应数据返回给客户端:

image-20230306105550481

参照这个思路,使用 AWS 或者其他云服务厂商的 API 网关+ Lambda 函数(云函数)也可以实现类似的功能。不想折腾的同学可以使用极客书房提供的腾讯云代理,只需要在发起请求时将 OpenAI 的 API 域名 api.openai.com 替换成 openai.aiproxy.xyz 即可:

代码里也是一样,以我开发的 geekr/openai-laravel 为例,它首先从配置文件读取 base_uri,然后在发起请求的时候,以自定义的 $baseUri 为准发起请求,这样就可以通过代理的方式发起对 OpenAI 的接口请求了:

<?php

declare(strict_types=1);

namespace GeekrOpenAI\Laravel\Client;

use GuzzleHttp\Client as GuzzleClient;
use OpenAI\Client;
use OpenAI\Transporters\HttpTransporter;
use OpenAI\ValueObjects\ApiKey;
use OpenAI\ValueObjects\Transporter\BaseUri;
use OpenAI\ValueObjects\Transporter\Headers;

class OpenAI
{
    /**
     * Creates a new Open AI Client with the given API token.
     */
    public static function client(string $apiKey, $baseUri, string $organization = null): Client
    {
        $apiKey = ApiKey::from($apiKey);

        $baseUri = BaseUri::from($baseUri);

        $headers = Headers::withAuthorization($apiKey);

        if ($organization !== null) {
            $headers = $headers->withOrganization($organization);
        }

        $client = new GuzzleClient();

        $transporter = new HttpTransporter($client, $baseUri, $headers);

        return new Client($transporter);
    }
}

这个代理的源码我也提交到 Github 仓库里了,其实就是做一层转发而已:GO-OPENAI-PROXY,觉得有帮助就给个 star 吧。

10 Comments

  1. antennababby
    antennababby 2023年3月6日

    有docker版的么.php不是很熟悉.

  2. WilliamNing
    WilliamNing 2023年3月6日

    先过一遍,明天实践下~

  3. ab0029
    ab0029 2023年3月7日

    如果使用官方SDK,可以使用下面的方法代理

    use GuzzleHttp\Client as GuzzleClient;
    use OpenAI\Client;
    use OpenAI\Transporters\HttpTransporter;
    use OpenAI\ValueObjects\ApiKey;
    use OpenAI\ValueObjects\Transporter\BaseUri;
    use OpenAI\ValueObjects\Transporter\Headers;
    
    $openai = static function(string $apiKey, string $organization = null) {
        $apiKey = ApiKey::from($apiKey);
    
    <code>$baseUri = BaseUri::from('api.openai.com/v1');
    
    $headers = Headers::withAuthorization($apiKey);
    
    if ($organization !== null) {
        $headers = $headers->withOrganization($organization);
    }
    
    $headers = $headers->withAccept('text/event-stream');
    
    $client = new GuzzleClient([
        // 这里填代理地址
        'proxy' => 'http://127.0.0.1:10809',
        'stream' => true,
    ]);
    
    $transporter = new HttpTransporter($client, $baseUri, $headers);
    
    return new Client($transporter);
    </code>
    
    };
    
    $client = $openai('xxxxxxxxxx');
    
    $result = $client->chat()->create([
        'model' => 'gpt-3.5-turbo',
        'messages' => $messages = [
            ['role' => 'system', 'content' => '你是一个文案高手'],
            ['role' => 'user', 'content' => '生成一段200字的话语'],
        ],
        'n' => 3,
    ]);
    
  4. gcsyzy
    gcsyzy 2023年3月18日

    学院君你好,我是一名在校学生 我在使用代理地址:openai.aiproxy.xyz 时 , 第一次成功了,之后都是出现 401 的错误 ,怎么解决呀

      • gcsyzy
        gcsyzy 2023年3月19日

        我用的就是这个域名,我评论的时候复制错了,第一次成功,后面401是因为账号被封了😂

发表回复