Skip to content

Commit 97fe139

Browse files
committed
增加对语音聊天咨询的支持
1 parent fd5ff6e commit 97fe139

File tree

10 files changed

+465
-9
lines changed

10 files changed

+465
-9
lines changed

app/Http/Controllers/ChatController.php

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@
44

55
use Illuminate\Http\RedirectResponse;
66
use Illuminate\Http\Request;
7+
use Illuminate\Support\Str;
78
use GeekrOpenAI\Laravel\Facades\OpenAI;
89
use Illuminate\Support\Facades\Redirect;
10+
use Illuminate\Support\Facades\Storage;
11+
use Illuminate\Validation\Rules\File;
912
use Inertia\Inertia;
1013

1114
class ChatController extends Controller
@@ -38,7 +41,72 @@ public function chat(Request $request): RedirectResponse
3841
'messages' => $messages
3942
]);
4043

41-
$messages[] = ['role' => 'assistant', 'content' => $response->choices[0]->message->content];
44+
$respText = '';
45+
if (empty($response->choices[0]->message->content)) {
46+
$respText = '对不起,我没有理解你的意思,请重试';
47+
} else {
48+
$respText = $response->choices[0]->message->content;
49+
}
50+
51+
$messages[] = ['role' => 'assistant', 'content' => $respText];
52+
53+
$request->session()->put('messages', $messages);
54+
55+
return Redirect::to('/');
56+
}
57+
58+
/**
59+
* Handle the incoming audio prompt.
60+
*/
61+
public function audio(Request $request): RedirectResponse
62+
{
63+
$request->validate([
64+
'audio' => [
65+
'required',
66+
File::types(['mp3', 'mp4', 'mpeg', 'mpga', 'm4a', 'wav', 'webm'])
67+
->min(1) // 最小不低于 1 KB
68+
->max(10 * 1024), // 最大不超过 10 MB
69+
]
70+
]);
71+
72+
// 保存到本地
73+
$fileName = Str::uuid() . '.wav';
74+
$dir = 'audios' . date('/Y/m/d', time());
75+
$path = $request->audio->storeAs($dir, $fileName, 'local');
76+
77+
$messages = $request->session()->get('messages', [
78+
['role' => 'system', 'content' => 'You are GeekChat - A ChatGPT clone. Answer as concisely as possible. 把简体中文作为第一语言']
79+
]);
80+
81+
// $path = 'audios/2023/03/09/test.wav';(测试用)
82+
// 调用 speech to text API 将语音转化为文字
83+
$response = OpenAI::audio()->transcribe([
84+
'model' => 'whisper-1',
85+
'file' => fopen(Storage::disk('local')->path($path), 'r'),
86+
'response_format' => 'verbose_json',
87+
]);
88+
89+
if (empty($response->text)) {
90+
$messages[] = ['role' => 'system', 'content' => '对不起,我没有听清你说的话,请再试一次'];
91+
$request->session()->put('messages', $messages);
92+
return Redirect::to('/');
93+
}
94+
95+
// 接下来的流程和 ChatGPT 一样
96+
$messages[] = ['role' => 'user', 'content' => $response->text];
97+
98+
$response = OpenAI::chat()->create([
99+
'model' => 'gpt-3.5-turbo',
100+
'messages' => $messages
101+
]);
102+
103+
$respText = '';
104+
if (empty($response->choices[0]->message->content)) {
105+
$respText = '对不起,我没有听明白你的意思,请再说一遍';
106+
} else {
107+
$respText = $response->choices[0]->message->content;
108+
}
109+
$messages[] = ['role' => 'assistant', 'content' => $respText];
42110

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

app/Providers/RouteServiceProvider.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,5 +44,11 @@ protected function configureRateLimiting(): void
4444
RateLimiter::for('api', function (Request $request) {
4545
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
4646
});
47+
RateLimiter::for('chat', function (Request $request) {
48+
return Limit::perHour(60)->by($request->user()?->id ?: $request->ip());
49+
});
50+
RateLimiter::for('audio', function (Request $request) {
51+
return Limit::perHour(30)->by($request->user()?->id ?: $request->ip());
52+
});
4753
}
4854
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
<template>
2+
<div>
3+
<icon-button :class="buttonClass" v-if="recording" name="stop" @click="toggleRecording" />
4+
<icon-button :class="buttonClass" v-else name="mic" @click="toggleRecording" />
5+
</div>
6+
</template>
7+
8+
<script>
9+
import Recorder from "../lib/Recorder";
10+
import IconButton from "./IconButton.vue";
11+
12+
const ERROR_MESSAGE = "无法使用麦克风,请确保具备硬件条件以及授权应用使用你的麦克风";
13+
const ERROR_TIMEOUT_MESSAGE = "体验版目前仅支持30秒以内语音, 请重试";
14+
const ERROR_BLOB_MESSAGE = "录音数据为空, 点击小话筒->开始讲话->讲完点终止键,再来一次吧";
15+
16+
export default {
17+
name: "AudioWidget",
18+
props: {
19+
// in seconds
20+
time: { type: Number, default: 30 },
21+
bitRate: { type: Number, default: 128 },
22+
sampleRate: { type: Number, default: 44100 },
23+
},
24+
components: {
25+
IconButton,
26+
},
27+
data() {
28+
return {
29+
recording: false,
30+
recordedAudio: null,
31+
recordedBlob: null,
32+
recorder: null,
33+
errorMessage: null,
34+
};
35+
},
36+
computed: {
37+
buttonClass() {
38+
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";
39+
}
40+
},
41+
beforeUnmount() {
42+
if (this.recording) {
43+
this.stopRecorder();
44+
}
45+
},
46+
methods: {
47+
toggleRecording() {
48+
// 用户点击按钮触发
49+
this.recording = !this.recording;
50+
if (this.recording) {
51+
// 开始录音
52+
this.initRecorder();
53+
} else {
54+
// 结束录音
55+
this.stopRecording();
56+
}
57+
},
58+
initRecorder() {
59+
// 初始化Recoder
60+
this.recorder = new Recorder({
61+
micFailed: this.micFailed,
62+
bitRate: this.bitRate,
63+
sampleRate: this.sampleRate,
64+
});
65+
// 开始录音
66+
this.recorder.start();
67+
this.errorMessage = null;
68+
},
69+
stopRecording() {
70+
// 停止录音
71+
this.recorder.stop();
72+
const recordList = this.recorder.recordList();
73+
this.recordedAudio = recordList[0].url;
74+
this.recordedBlob = recordList[0].blob;
75+
// 录音数据不为空触发上传
76+
if (this.recordedAudio && this.recordedBlob) {
77+
// 录音成功,先判断时长
78+
if (this.recorder.duration > this.time) {
79+
this.errorMessage = ERROR_TIMEOUT_MESSAGE;
80+
this.$emit('audio-failed', this.errorMessage);
81+
return;
82+
}
83+
// 录音数据为空,不处理
84+
if (!this.recordedBlob) {
85+
this.errorMessage = ERROR_BLOB_MESSAGE;
86+
this.$emit('audio-failed', this.errorMessage);
87+
return;
88+
}
89+
// 提交录音数据给后端
90+
this.$emit('audio-upload', this.recordedBlob);
91+
}
92+
},
93+
micFailed() {
94+
// 录音失败
95+
this.recording = false;
96+
this.errorMessage = ERROR_MESSAGE;
97+
this.$emit('audio-failed', this.errorMessage);
98+
},
99+
},
100+
};
101+
</script>
102+
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<template>
2+
<div v-html="icons[name]"></div>
3+
</template>
4+
5+
<script>
6+
export default {
7+
props: {
8+
name: { type: String },
9+
},
10+
data() {
11+
return {
12+
icons: {
13+
download: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="w-6 h-6"><path d="M19 12v7H5v-7H3v7c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2v-7h-2zm-6 .67l2.59-2.58L17 11.5l-5 5-5-5 1.41-1.41L11 12.67V3h2z"/><path fill="none" d="M0 0h24v24H0z"/></svg>',
14+
mic: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="w-6 h-6"><path d="M12 14c1.66 0 2.99-1.34 2.99-3L15 5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5.3-3c0 3-2.54 5.1-5.3 5.1S6.7 14 6.7 11H5c0 3.41 2.72 6.23 6 6.72V21h2v-3.28c3.28-.48 6-3.3 6-6.72h-1.7z"/><path d="M0 0h24v24H0z" fill="none"/></svg>',
15+
play: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="w-6 h-6"><path d="M8 5v14l11-7z"/><path d="M0 0h24v24H0z" fill="none"/></svg>',
16+
save: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="w-6 h-6"><path d="M0 0h24v24H0z" fill="none"/><path d="M17 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V7l-4-4zm-5 16c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3zm3-10H5V5h10v4z"/></svg>',
17+
stop: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="w-6 h-6"><path d="M0 0h24v24H0z" fill="none"/><path d="M6 6h12v12H6z"/></svg>',
18+
volume: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="w-6 h-6"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/><path d="M0 0h24v24H0z" fill="#none"/></svg>',
19+
},
20+
};
21+
},
22+
};
23+
</script>
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<template>
2+
<button @click="clickButton" :style="{ 'background-color': color }"
3+
class="p-3 mt-8 text-black rounded-md">
4+
Submit
5+
</button>
6+
</template>
7+
8+
<script>
9+
export default {
10+
emits: ['submit'],
11+
props: {
12+
color: {
13+
type: String,
14+
},
15+
},
16+
methods: {
17+
clickButton() {
18+
this.$emit("submit");
19+
},
20+
},
21+
};
22+
</script>

resources/js/Pages/Welcome.vue

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
<script setup>
2+
import AudioWidget from '@/Components/AudioWidget.vue';
23
import { Head, Link, useForm, router } from '@inertiajs/vue3';
4+
import axios from 'axios';
35
import { reactive } from 'vue';
46
57
const props = defineProps({
@@ -22,11 +24,16 @@ const chat = () => {
2224
onStart: () => {
2325
data.error = ''
2426
form.reset()
25-
data.toast = 'GeekChat正在思考如何回答,请稍候...'
27+
data.toast = 'GeekChat正在思考如何回答您的问题,请稍候...'
2628
},
2729
onFinish: response => {
2830
if (response.status >= 400) {
29-
data.error = '请求处理失败,请重试'
31+
if (response.status == 429) {
32+
data.error = '请求过于频繁,请稍后再试'
33+
} else {
34+
data.error = '请求处理失败,请重试'
35+
}
36+
3037
}
3138
data.toast = ''
3239
scrollToButtom()
@@ -45,6 +52,32 @@ const scrollToButtom = () => {
4552
const msgAnchor = document.querySelector('#msg-anchor')
4653
msgAnchor.scrollIntoView({ behavior: 'smooth' })
4754
}
55+
56+
const audio = (blob) => {
57+
const formData = new FormData();
58+
formData.append('audio', blob);
59+
data.error = ''
60+
data.toast = 'GeekChat正在识别语音并思考如何回答您的问题,请稍候...'
61+
axios.post(route('audio'), formData)
62+
.then(response => {
63+
data.toast = ''
64+
location.reload();
65+
scrollToButtom()
66+
}).catch(error => {
67+
data.toast = ''
68+
if (error.includes('429')) {
69+
data.error = '请求过于频繁,请稍后再试'
70+
} else {
71+
data.error = '处理语音失败,可能没录音成功(按下话筒图标->开始讲话->讲完按下终止图标,操作不要太快),再来一次试试吧'
72+
}
73+
scrollToButtom()
74+
})
75+
}
76+
77+
const audioFailed = (error) => {
78+
data.error = error
79+
data.toast = ''
80+
}
4881
</script>
4982

5083
<template>
@@ -87,9 +120,12 @@ const scrollToButtom = () => {
87120
</div>
88121

89122
<form class="p-4 flex space-x-4 justify-center items-center" @submit.prevent="chat">
90-
<input id="message" placeholder="输入你的问题..." type="text" name="prompt" autocomplete="off" v-model="form.prompt"
91-
class="border rounded-md p-2 flex-1" required />
92-
<button class=""
123+
<div class="relative w-full">
124+
<input id="message" placeholder="输入你的问题..." type="text" name="prompt" autocomplete="off" v-model="form.prompt"
125+
class="w-full first-letter:border rounded-md p-2 flex-1" required />
126+
<audio-widget @audio-upload="audio" @audio-failed="audioFailed" />
127+
</div>
128+
<button
93129
: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 }"
94130
:disabled="form.processing" type="submit">
95131
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
@@ -111,9 +147,9 @@ const scrollToButtom = () => {
111147

112148
<footer class="text-center sm:text-left">
113149
<div class="p-4 text-center text-neutral-700">
114-
GeekChat体验版由
150+
GeekChat 由
115151
<a href="https://geekr.dev" target="_blank" class="text-neutral-800 dark:text-neutral-400">极客书房</a>
116-
友情赞助
152+
友情赞助,你可以通过文字或语音进行聊天和咨询
117153
</div>
118154
</footer>
119155
</template>

0 commit comments

Comments
 (0)