Press "Enter" to skip to content

基于 Laravel + Vue + Whisper 实现语音版 ChatGPT

如果你想先体验一把语音版 ChatGPT,可以直接跳到文章最后点击演示链接。

引入 Breeze 重构前端页面

上篇教程中,学院君给大家演示了如何基于 Laravel + OpenAI 快速构建 ChatGPT 网页版(GeekChat),在这个版本中,前端实现非常简单,只是通过一个 PHP 页面完成,如果想要基于现代前端技术栈完成更复杂的页面功能,需要引入前端框架进行重构。这里我选择了 Vue 框架,并通过胶水工具库 Inertia.js 粘合 Laravel 和 Vue,让前后端可以完全分离,又能很好地组合。

这一切都不需要我们自己做什么额外的工作,在 Laravel 10 中,使用官方入门套件 Breeze 扩展包提供的脚手架通过几个命令就能快速完成相关的初始化:

composer require laravel/breeze --dev
php artisan breeze:install vue
npm install

完成上面几个操作后,就已经在项目中集成了 Inertia 以及基于 Vue3 的前端依赖和示例模板,因为我们这里不需要登录认证和用户信息编辑,可以去除对应的代码,隐藏认证路由,然后把之前的 ChatGPT 聊天页面前端代码从 welcome.blade.php 通过 Vue 重写到 resources/js/Pages/Welcome.vue 中:

<script setup>
import { Head, Link, useForm, router } from '@inertiajs/vue3';
import { reactive } from 'vue';
const props = defineProps({
    canLogin: Boolean,
    canRegister: Boolean,
    messages: Array
})
const form = useForm({
    prompt: ''
});
const data = reactive({
    error: '',
    toast: ''
})
const chat = () => {
    form.post(route('chat'), {
        onStart: () => {
            data.error = ''
            form.reset()
            data.toast = 'GeekChat正在思考如何回答,请稍候...'
        },
        onFinish: response => {
            if (response.status >= 400) {
                data.error = '请求处理失败,请重试'
            }
            data.toast = ''
            scrollToButtom()
        }
    });
}
const reset = () => {
    axios.get(route('reset'))
        .then(response => {
            router.reload()
        })
}
const scrollToButtom = () => {
    const msgAnchor = document.querySelector('#msg-anchor')
    msgAnchor.scrollIntoView({ behavior: 'smooth' })
}
</script>

<template>
    <Head title="GeekChat - ChatGPT免费体验版">
        <link rel="shortcut icon" type="image/png" href="https://image.gstatics.cn/icon/geekchat.png">
    </Head>

    <div class="flex flex-col space-y-4 p-4">
        <div v-for="(message, index) in messages" :key="index"
            :class="[message.role == 'assistant' ? 'flex rounded-lg p-4 bg-green-200 flex-reverse' : 'flex rounded-lg p-4 bg-blue-200']">
            <div class="ml-4">
                <div class="text-lg">
                    <a v-if="message.role == 'assistant'" href="#" class="font-medium text-gray-900">GeekChat</a>
                    <a v-else href="#" class="font-medium text-gray-900">你</a>
                </div>
                <div class="mt-1">
                    <p class="text-gray-600">
                        <markdown :source="message.content" />
                    </p>
                </div>
            </div>
        </div>
        <div id="msg-anchor"></div>
        <!-- 处理中提示 -->
        <div v-if="data.toast" class="flex rounded-lg p-4 bg-green-200 flex-reverse'">
            <div class="ml-4">
                <div class="mt-1">
                    <p class="text-gray-500">{{ data.toast }}</p>
                </div>
            </div>
        </div>
        <!-- 响应错误提示 -->
        <div v-if="data.error" class="flex rounded-lg p-4 bg-red-400 flex-reverse'">
            <div class="ml-4">
                <div class="mt-1">
                    <p class="text-gray-100">{{ data.error }}</p>
                </div>
            </div>
        </div>
    </div>

    <form class="p-4 flex space-x-4 justify-center items-center" @submit.prevent="chat">
        <input id="message" placeholder="输入你的问题..." type="text" name="prompt" autocomplete="off" v-model="form.prompt"
            class="border rounded-md  p-2 flex-1" required />
        <button class=""
            :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': true, 'opacity-25': form.processing }"
            :disabled="form.processing" 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"
            @click="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>

    <footer class="text-center sm:text-left">
        <div class="p-4 text-center text-neutral-700">
            GeekChat体验版由
            <a href="https://geekr.dev" target="_blank" class="text-neutral-800 dark:text-neutral-400">极客书房</a>
            友情赞助
        </div>
    </footer>
</template>

因为现在前端页面是通过 Inertia 作为中间胶水层渲染的,所以相应的渲染代码也要调整下:

public function index()
{
    ...
    return Inertia::render('Welcome', [
        'messages' => $messages->toArray(),
    ]);
}

public function chat(Request $request): RedirectResponse
{
    ...
    return Redirect::to('/');
}


public function reset(Request $request): RedirectResponse
{
    ...
    return Redirect::to('/');
}

最后,因为 Tailwind 也是通过前端编译引入的,所以记得在 resources/css/app.css 中引入 Tailwind:

@tailwind base;
@tailwind components;
@tailwind utilities;

更多详细代码这里就不一一列举了,完整的提交记录在这里:基于Inertia+Vue3重构GeekChat功能 · geekr-dev/geekchat@fd5ff6e

当然,你也可以通过 breeze 分支检出这个提交的所有代码:

git checkout breeze

完成以上工作可以运行 npm run dev 编译前端代码,然后运行 php artisan serve 命令启动本地 HTTP 服务器,就可以在浏览器中看到最新的 ChatGPT 体验版效果,和上篇的结果应该是一样的:

img

另外这里一个额外的工作是前端对 Markdown 文本的处理,这里使用了第三方组件 vue3-markdown-it,并将其注册为了全局组件,你可以在 resources/js/app.js 中找到它。

实现 ChatGPT 对语音的支持

之所以要做这个前端重构,不是为了用 Vue 而用 Vue,而是为了给接下来在前端页面添加语音组件做基础技术准备。

接下来,我们就来实现 ChatGPT 体验版对语音聊天的支持。

整体架构

开始之前,需要梳理下整体架构,如下所示:

核心流程:

在 Vue 语音组件中,当用户开始录音后,基于 HTML5 提供的 MediaRecorder API 录制用户声音,当用户停止录音后,将声音数据流编码为常见音频格式(WAV/MP3等),通过 Axios 上传到后端音频处理接口 /audio,之后进入音频解析识别流程,这里调用的是 OpenAI 提供的 speech to text 接口,指定该接口基于 Whisper 模型进行语音识别,并转化为文本返回。再之后流程就和 ChatGPT 接口一样了,不再赘述,最后把处理结果通过文本返回到前端页面展示,从而实现完整的语音聊天功能。

可以看到,所谓的语音聊天,最终基于的还是文本,前面那么多操作,都是为了将语音转化为文本。

前端 Vue 录音组件

梳理好流程后,接下来就是按部就班地分模块编码实现。

首先,我们来编写 Vue 语音组件,这里我借鉴了 Github 上的开源项目 vue-audio-tapir,但是把 UI(包含对 Tailwind 的兼容)和业务流程做了调整,将小话筒整合到了输入框内,类似 Google 搜索框那样:

image-20230310220851799

这个小话筒对应的就是一个独立的 AudioWidget 组件:

<template>
    <div>
        <icon-button :class="buttonClass" v-if="recording" name="stop" @click="toggleRecording" />
        <icon-button :class="buttonClass" v-else name="mic" @click="toggleRecording" />
    </div>
</template>
  
<script>
import Recorder from "../lib/Recorder";
import IconButton from "./IconButton.vue";

const ERROR_MESSAGE = "无法使用麦克风,请确保具备硬件条件以及授权应用使用你的麦克风";
const ERROR_TIMEOUT_MESSAGE = "体验版目前仅支持30秒以内语音, 请重试";
const ERROR_BLOB_MESSAGE = "录音数据为空, 点击小话筒->开始讲话->讲完点终止键,再来一次吧";

export default {
    name: "AudioWidget",
    props: {
        // in seconds
        time: { type: Number, default: 30 },
        bitRate: { type: Number, default: 128 },
        sampleRate: { type: Number, default: 44100 },
    },
    components: {
        IconButton,
    },
    data() {
        return {
            recording: false,
            recordedAudio: null,
            recordedBlob: null,
            recorder: null,
            errorMessage: null,
        };
    },
    computed: {
        buttonClass() {
            return "absolute right-0 top-0 h-full flex items-center justify-center mx-auto px-2 py-2 fill-current rounded-md text-sm cursor-pointer";
        }
    },
    beforeUnmount() {
        if (this.recording) {
            this.stopRecorder();
        }
    },
    methods: {
        toggleRecording() {
            // 用户点击按钮触发
            this.recording = !this.recording;
            if (this.recording) {
                // 开始录音
                this.initRecorder();
            } else {
                // 结束录音
                this.stopRecording();
            }
        },
        initRecorder() {
            // 初始化Recoder
            this.recorder = new Recorder({
                micFailed: this.micFailed,
                bitRate: this.bitRate,
                sampleRate: this.sampleRate,
            });
            // 开始录音
            this.recorder.start();
            this.errorMessage = null;
        },
        stopRecording() {
            // 停止录音
            this.recorder.stop();
            const recordList = this.recorder.recordList();
            this.recordedAudio = recordList[0].url;
            this.recordedBlob = recordList[0].blob;
            // 录音数据不为空触发上传
            if (this.recordedAudio && this.recordedBlob) {
                // 录音成功,先判断时长
                if (this.recorder.duration > this.time) {
                    this.errorMessage = ERROR_TIMEOUT_MESSAGE;
                    this.$emit('audio-failed', this.errorMessage);
                    return;
                }
                // 录音数据为空,不处理
                if (!this.recordedBlob) {
                    this.errorMessage = ERROR_BLOB_MESSAGE;
                    this.$emit('audio-failed', this.errorMessage);
                    return;
                }
                // 提交录音数据给后端
                this.$emit('audio-upload', this.recordedBlob);
            }
        },
        micFailed() {
            // 录音失败
            this.recording = false;
            this.errorMessage = ERROR_MESSAGE;
            this.$emit('audio-failed', this.errorMessage);
        },
    },
};
</script>

这里面包含录音对象初始化,开始录音、结束录音以及录音上传的逻辑,代码注释比较详细,就不详细介绍了,里面还使用到了 IconButton 组件、 Recorder API 以及音频编码实现,这些都可以在代码仓库中找到:geekchat/resources/js at master · geekr-dev/geekchat

语音组件是嵌入在 Welcome.vue 页面中的子组件,而语音录制完毕需要触发父页面调用上传接口,这里子组件向父组件通信需要用到 $emit 函数触发:

<div class="relative w-full">
    <input id="message" placeholder="输入你的问题..." type="text" name="prompt" autocomplete="off" v-model="form.prompt"
        class="w-full first-letter:border rounded-md p-2 flex-1" required />
    <audio-widget @audio-upload="audio" @audio-failed="audioFailed" />
</div>

然后在父页面中调用对应的函数执行回调函数处理:

const audio = (blob) => {
    const formData = new FormData();
    formData.append('audio', blob);
    data.error = ''
    data.toast = 'GeekChat正在识别语音并思考如何回答您的问题,请稍候...'
    axios.post(route('audio'), formData)
        .then(response => {
            data.toast = ''
            location.reload();
            scrollToButtom()
        }).catch(error => {
            data.toast = ''
            if (error.includes('429')) {
                data.error = '请求过于频繁,请稍后再试'
            } else {
                data.error = '处理语音失败,可能没录音成功(按下话筒图标->开始讲话->讲完按下终止图标,操作不要太快),再来一次试试吧'
            }
            scrollToButtom()
        })
}
​
const audioFailed = (error) => {
    data.error = error
    data.toast = ''
}

对于上传录音操作,我们将会调用后端的 /audio 接口执行后续的语音处理操作。

后端音频识别处理

前端核心逻辑大概就这些,我们再来看后端的处理流程:

public function audio(Request $request): RedirectResponse
{
    $request->validate([
        'audio' => [
            'required',
            File::types(['mp3', 'mp4', 'mpeg', 'mpga', 'm4a', 'wav', 'webm'])
                ->min(1)  // 最小不低于 1 KB
                ->max(10 * 1024), // 最大不超过 10 MB
        ]
    ]);
​
    // 保存到本地
    $fileName = Str::uuid() . '.wav';
    $dir = 'audios' . date('/Y/m/d', time());
    $path = $request->audio->storeAs($dir, $fileName, 'local');
​
    $messages = $request->session()->get('messages', [
        ['role' => 'system', 'content' => 'You are GeekChat - A ChatGPT clone. Answer as concisely as possible. 把简体中文作为第一语言']
    ]);
​
    // $path = 'audios/2023/03/09/test.wav';(测试用)
    // 调用 speech to text API 将语音转化为文字
    $response = OpenAI::audio()->transcribe([
        'model' => 'whisper-1',
        'file' => fopen(Storage::disk('local')->path($path), 'r'),
        'response_format' => 'verbose_json',
    ]);
​
    if (empty($response->text)) {
        $messages[] = ['role' => 'system', 'content' => '对不起,我没有听清你说的话,请再试一次'];
        $request->session()->put('messages', $messages);
        return Redirect::to('/');
    }
​
    // 接下来的流程和 ChatGPT 一样
    $messages[] = ['role' => 'user', 'content' => $response->text];
​
    $response = OpenAI::chat()->create([
        'model' => 'gpt-3.5-turbo',
        'messages' => $messages
    ]);
​
    $respText = '';
    if (empty($response->choices[0]->message->content)) {
        $respText = '对不起,我没有听明白你的意思,请再说一遍';
    } else {
        $respText = $response->choices[0]->message->content;
    }
    $messages[] = ['role' => 'assistant', 'content' => $respText];
​
    $request->session()->put('messages', $messages);
    
    
​
    return Redirect::to('/');
}

如果你忘掉了这块业务逻辑,可以对照前面的架构图看:

  • 首先对音频文件进行验证,要求不超过10M,且必须是指定的音频格式;
  • 然后调用 OpenAI 的 speech to text 接口,在我们使用的 SDK 中,对应的方法是 OpenAI::audio,注意这里指定了语音识别模型是 whisper-1
  • 将 OpenAI 接口返回的响应数据(从音频中解析出来的文本)作为用户输入的文本 prompt,进入 ChatGPT API 调用流程,完成对用户问题的处理,最终以文本返回(如果需要返回音频,这里再调用第三方 API 将文本转化为音频即可),至此,就完成了语音聊天的完整功能。

其他杂项

前后端分离后,需要注意 CORS 跨域请求问题,以及如果后端使用了 HTTPS,则前端资源文件对应的域名也要强制使用 HTTPS,对应的代码项目中都包含了,可以自己留意下,还有就是如果配置了前端资源使用 CDN,还要在.env环境配置文件加入 ASSET_URL=https://CDN域名/,以便让前端编译生成的资源 URL 域名切换到 CDN 域名。

最终效果

至此,我们已经完成了基于 Inertia + Vue3 重构前端页面,以及让 ChatGPT 体验版支持语音聊天咨询的全部编码工作,运行 npm run dev 编译前端资源,就可以在浏览器看到最终的效果了。你可以点击小话筒开启录音,录音完毕点击终止按钮结束录音,接下来,就会把录音数据提交给后端验证、识别、转化为 ChatGPT 流程完成聊天处理,最终以文本数据返回到聊天页面:

image

以上对话由语音聊天驱动生成。


周末的时候,给前端 UI 又做了一次重构,让整体用户体验更加友好,这样一来,无论从界面、体验还是功能性来说,GeekChat 都具备一款小而美产品的基本素质了:

当然,你也可以直接在线上体验这个体验版:GeekChat,经过数次迭代,现在它已经成了一个支持文字、语音、翻译、画图的多功能聊天机器人:

完整的项目源码也已经开源到 Github:geekr-dev/geekchat: GeekChat,以后会持续迭代更新,欢迎 star。如果你有什么问题,欢迎通过评论框与我交流,或者通过扫描页面右侧的二维码加我微信。

发表回复