Sumber terbuka PHP murni dari @QIAatue mengimplementasikan panggilan streaming GPT dan front-end real-time cetak webUI.
Diperbarui 13 April:
1. Kecepatannya lambat karena OpenAi memiliki kecepatan terbatas untuk akun gratis.
2. Batas kecepatan berarti bahwa ketika streaming permintaan, dibutuhkan sekitar 20 detik untuk mengembalikan token pertama, dan akun terikat ke kartu kredit adalah sekitar 2 detik;
/
├─ /class
│ ├─ Class.ChatGPT.php
│ ├─ Class.DFA.php
│ ├─ Class.StreamHandler.php
├─ /static
│ ├─ css
│ │ ├─ chat.css
│ │ ├─ monokai-sublime.css
│ ├─ js
│ │ ├─ chat.js
│ │ ├─ highlight.min.js
│ │ ├─ marked.min.js
├─ /chat.php
├─ /index.html
├─ /README.md
├─ /sensitive_words.txt
Direktori/File | menjelaskan |
---|---|
/ | Direktori root program |
/kelas | Direktori File PHP |
/class/class.chatgpt.php | Kelas chatgpt, digunakan untuk memproses permintaan front-end dan mengirimkan permintaan ke antarmuka openai |
/class/class.dfa.php | Kelas DFA untuk penggantian checksum kata sensitif |
/class/class.streamhandler.php | Kelas StreamHandler, digunakan untuk memproses data yang dikembalikan oleh OpenAi secara real time |
/statis | Simpan semua file statis yang diperlukan untuk halaman front-end |
/statis/css | Simpan semua file CSS di halaman front-end |
/static/css/chat.css | File gaya obrolan halaman front-end |
/static/css/monokai-sublime.css | Sorot file gaya tema untuk kode penyorotan plugin |
/statis/js | Simpan semua file JS di halaman front-end |
/static/js/chat.js | Kode JS interaksi obrolan front-end |
/static/js/highlight.min.js | Kode Menyoroti Perpustakaan JS |
/static/js/marked.min.js | Markdown Parsing JS Library |
/chat.php | File entri backend untuk permintaan obrolan front-end, di mana file kelas php diperkenalkan |
/index.html | Halaman front-end kode html |
/Readme.md | File Deskripsi Gudang |
/sensitive_words.txt | File kata sensitif, setiap baris, Anda perlu mengumpulkan kata -kata sensitif sendiri, Anda juga dapat menambahkan saya di wechat (sama seperti ID github) untuk menemukan saya. |
Kode proyek ini tidak menggunakan kerangka kerja apa pun, juga tidak memperkenalkan pustaka backend pihak ketiga.
Satu -satunya dua hal yang harus dilakukan adalah mengisi kunci API Anda sendiri.
Setelah mendapatkan kode sumber, modifikasi chat.php
, isi tombol API openai dan masuk. Untuk detailnya, silakan lihat:
$ chat = new ChatGPT ([
' api_key ' => '此处需要填入 openai 的 api key ' ,
]);
Jika fungsi deteksi kata sensitif diaktifkan, Anda perlu memasukkan garis kata sensitif ke dalam file sensitive_words_sdfdsfvdfs5v56v5dfvdf.txt
.
Saya membuka grup WeChat dan selamat datang untuk bergabung dengan grup untuk berkomunikasi:
Di backend class.chatgpt.php, gunakan curl untuk memulai permintaan untuk openai, gunakan Curl's CURLOPT_WRITEFUNCTION
untuk mengatur fungsi callback, dan pada saat yang sama, 'stream' => true
dalam parameter permintaan memberi tahu OpenAi untuk mengaktifkan streaming.
Kami menggunakan curl_setopt($ch, CURLOPT_WRITEFUNCTION, [$this->streamHandler, 'callback']);
$this->streamHandler
callback
']);
OpenAi akan mengembalikan data: {"id":"","object":"","created":1679616251,"model":"","choices":[{"delta":{"content":""},"index":0,"finish_reason":null}]}
format string, dan choices[0]['delta']['content']
. gments dan tidak dapat secara langsung mendapatkan data seperti ini.
Selain itu, karena masalah transmisi jaringan, data yang diterima oleh fungsi callback
setiap kali tidak harus hanya memiliki satu data: {"key":"value"}
, yang mungkin hanya memiliki setengah bagian, atau beberapa bagian, atau setengah bagian.
Jadi kami menambahkan atribut data_buffer
ke kelas StreamHandler
untuk menyimpan setengah dari data yang tidak dapat diuraikan.
Di sini, beberapa pemrosesan khusus dilakukan berdasarkan format data pengembalian openai, kode spesifiknya adalah sebagai berikut:
public function callback ( $ ch , $ data ) {
$ this -> counter += 1 ;
file_put_contents ( ' ./log/data. ' . $ this -> qmd5 . ' .log ' , $ this -> counter . ' == ' . $ data . PHP_EOL . ' -------------------- ' . PHP_EOL , FILE_APPEND );
$ result = json_decode ( $ data , TRUE );
if ( is_array ( $ result )){
$ this -> end ( ' openai 请求错误: ' . json_encode ( $ result ));
return strlen ( $ data );
}
/*
此处步骤仅针对 openai 接口而言
每次触发回调函数时,里边会有多条data数据,需要分割
如某次收到 $data 如下所示:
data: {"id":"chatcmpl-6wimHHBt4hKFHEpFnNT2ryUeuRRJC","object":"chat.completion.chunk","created":1679453169,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"role":"assistant"},"index":0,"finish_reason":null}]}nndata: {"id":"chatcmpl-6wimHHBt4hKFHEpFnNT2ryUeuRRJC","object":"chat.completion.chunk","created":1679453169,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"以下"},"index":0,"finish_reason":null}]}nndata: {"id":"chatcmpl-6wimHHBt4hKFHEpFnNT2ryUeuRRJC","object":"chat.completion.chunk","created":1679453169,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"是"},"index":0,"finish_reason":null}]}nndata: {"id":"chatcmpl-6wimHHBt4hKFHEpFnNT2ryUeuRRJC","object":"chat.completion.chunk","created":1679453169,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"使用"},"index":0,"finish_reason":null}]}
最后两条一般是这样的:
data: {"id":"chatcmpl-6wimHHBt4hKFHEpFnNT2ryUeuRRJC","object":"chat.completion.chunk","created":1679453169,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{},"index":0,"finish_reason":"stop"}]}nndata: [DONE]
根据以上 openai 的数据格式,分割步骤如下:
*/
// 0、把上次缓冲区内数据拼接上本次的data
$ buffer = $ this -> data_buffer . $ data ;
//拼接完之后,要把缓冲字符串清空
$ this -> data_buffer = '' ;
// 1、把所有的 'data: {' 替换为 '{' ,'data: [' 换成 '['
$ buffer = str_replace ( ' data: { ' , ' { ' , $ buffer );
$ buffer = str_replace ( ' data: [ ' , ' [ ' , $ buffer );
// 2、把所有的 '}nn{' 替换维 '}[br]{' , '}nn[' 替换为 '}[br]['
$ buffer = str_replace ( ' } ' . PHP_EOL . PHP_EOL . ' { ' , ' }[br]{ ' , $ buffer );
$ buffer = str_replace ( ' } ' . PHP_EOL . PHP_EOL . ' [ ' , ' }[br][ ' , $ buffer );
// 3、用 '[br]' 分割成多行数组
$ lines = explode ( ' [br] ' , $ buffer );
// 4、循环处理每一行,对于最后一行需要判断是否是完整的json
$ line_c = count ( $ lines );
foreach ( $ lines as $ li => $ line ){
if ( trim ( $ line ) == ' [DONE] ' ){
//数据传输结束
$ this -> data_buffer = '' ;
$ this -> counter = 0 ;
$ this -> sensitive_check ();
$ this -> end ();
break ;
}
$ line_data = json_decode ( trim ( $ line ), TRUE );
if ( ! is_array ( $ line_data ) || ! isset ( $ line_data [ ' choices ' ]) || ! isset ( $ line_data [ ' choices ' ][ 0 ]) ){
if ( $ li == ( $ line_c - 1 )){
//如果是最后一行
$ this -> data_buffer = $ line ;
break ;
}
//如果是中间行无法json解析,则写入错误日志中
file_put_contents ( ' ./log/error. ' . $ this -> qmd5 . ' .log ' , json_encode ([ ' i ' => $ this -> counter , ' line ' => $ line , ' li ' => $ li ], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT ). PHP_EOL . PHP_EOL , FILE_APPEND );
continue ;
}
if ( isset ( $ line_data [ ' choices ' ][ 0 ][ ' delta ' ]) && isset ( $ line_data [ ' choices ' ][ 0 ][ ' delta ' ][ ' content ' ]) ){
$ this -> sensitive_check ( $ line_data [ ' choices ' ][ 0 ][ ' delta ' ][ ' content ' ]);
}
}
return strlen ( $ data );
}
Kami menggunakan algoritma DFA DfaFilter(确定有限自动机过滤器)通常是指一种用于文本处理和匹配的算法
menerapkan deteksi kata "DFA"是指“确定性有限自动机”(Deterministic Finite Automaton)
sensitif.
Kode kelas class.dfa.php ditulis dalam GPT4, dan kode implementasi spesifik ditampilkan dalam kode sumber.
Berikut adalah deskripsi cara menggunakannya.
$ dfa = new DFA ([
' words_file ' => ' ./sensitive_words_sdfdsfvdfs5v56v5dfvdf.txt ' ,
]);
CATATAN KHUSUS: Nama file dari string kacau secara khusus digunakan di sini untuk mencegah orang lain mengunduh file kata yang sensitif.
Setelah TRUE
, Anda dapat menggunakan $dfa->containsSensitiveWords($inputText)
FALSE
$outputText = $dfa->replaceWords($inputText)
*
$inputText
sensitive_words.txt
kata-kata sensitif.
Jika Anda tidak ingin mengaktifkan deteksi kata yang sensitif, komentar tiga kalimat berikut di chat.php
:
$ dfa = new DFA ([
' words_file ' => ' ./sensitive_words_sdfdsfvdfs5v56v5dfvdf.txt ' ,
]);
$ chat -> set_dfa ( $ dfa );
Jika deteksi kata sensitif tidak diaktifkan, setiap pengembalian openai akan dikembalikan ke ujung depan secara real time.
Jika deteksi kata sensitif diaktifkan, simbol baru dan jeda [',', '。', ';', '?', '!', '……']
dll $outputText = $dfa->replaceWords($inputText)
Untuk melakukan segmentasi kalimat.
Setelah menyalakan kata -kata sensitif, butuh waktu untuk memuat file kata sensitif.
Oleh karena itu, jika itu untuk penggunaan Anda sendiri, Anda tidak dapat mengaktifkan deteksi kata yang sensitif.
Hanya melihat komentar di chat.php
akan membuatnya lebih jelas:
/*
以下几行注释由 GPT4 生成
*/
// 这行代码用于关闭输出缓冲。关闭后,脚本的输出将立即发送到浏览器,而不是等待缓冲区填满或脚本执行完毕。
ini_set ( ' output_buffering ' , ' off ' );
// 这行代码禁用了 zlib 压缩。通常情况下,启用 zlib 压缩可以减小发送到浏览器的数据量,但对于服务器发送事件来说,实时性更重要,因此需要禁用压缩。
ini_set ( ' zlib.output_compression ' , false );
// 这行代码使用循环来清空所有当前激活的输出缓冲区。ob_end_flush() 函数会刷新并关闭最内层的输出缓冲区,@ 符号用于抑制可能出现的错误或警告。
while (@ ob_end_flush ()) {}
// 这行代码设置 HTTP 响应的 Content-Type 为 text/event-stream,这是服务器发送事件(SSE)的 MIME 类型。
header ( ' Content-Type: text/event-stream ' );
// 这行代码设置 HTTP 响应的 Cache-Control 为 no-cache,告诉浏览器不要缓存此响应。
header ( ' Cache-Control: no-cache ' );
// 这行代码设置 HTTP 响应的 Connection 为 keep-alive,保持长连接,以便服务器可以持续发送事件到客户端。
header ( ' Connection: keep-alive ' );
// 这行代码设置 HTTP 响应的自定义头部 X-Accel-Buffering 为 no,用于禁用某些代理或 Web 服务器(如 Nginx)的缓冲。
// 这有助于确保服务器发送事件在传输过程中不会受到缓冲影响。
header ( ' X-Accel-Buffering: no ' );
Setelah itu, setiap kali kami ingin mengembalikan data ke front-end, gunakan kode berikut:
echo ' data: ' . json_encode ([ ' time ' => date ( ' Y-m-d H:i:s ' ), ' content ' => '答: ' ]). PHP_EOL . PHP_EOL ;
flush ();
Di sini kami mendefinisikan format data yang kami gunakan, yang hanya berisi waktu dan konten.
Perhatikan bahwa setelah semua jawaban ditransmisikan, kita perlu menutup koneksi, dan kita dapat menggunakan kode berikut:
echo ' retry: 86400000 ' . PHP_EOL ; // 告诉前端如果发生错误,隔多久之后才轮询一次
echo ' event: close ' . PHP_EOL ; // 告诉前端,结束了,该说再见了
echo ' data: Connection closed ' . PHP_EOL . PHP_EOL ; // 告诉前端,连接已关闭
flush ();
JS front-end memungkinkan permintaan sumber daya melalui const eventSource = new EventSource(url);
;.
Setelah event.data
, server mengirim data ke ujung depan dalam format data: {"kev1":"value1","kev2":"value2"}
JSON.parse(event.data)
{"kev1":"value1","kev2":"value2"}
Kode spesifik ada dalam fungsi GetAnswer, seperti yang ditunjukkan di bawah ini:
function getAnswer ( inputValue ) {
inputValue = inputValue . replace ( '+' , '{[$add$]}' ) ;
const url = "./chat.php?q=" + inputValue ;
const eventSource = new EventSource ( url ) ;
eventSource . addEventListener ( "open" , ( event ) => {
console . log ( "连接已建立" , JSON . stringify ( event ) ) ;
} ) ;
eventSource . addEventListener ( "message" , ( event ) => {
//console.log("接收数据:", event);
try {
var result = JSON . parse ( event . data ) ;
if ( result . time && result . content ) {
answerWords . push ( result . content ) ;
contentIdx += 1 ;
}
} catch ( error ) {
console . log ( error ) ;
}
} ) ;
eventSource . addEventListener ( "error" , ( event ) => {
console . error ( "发生错误:" , JSON . stringify ( event ) ) ;
} ) ;
eventSource . addEventListener ( "close" , ( event ) => {
console . log ( "连接已关闭" , JSON . stringify ( event . data ) ) ;
eventSource . close ( ) ;
contentEnd = true ;
console . log ( ( new Date ( ) . getTime ( ) ) , 'answer end' ) ;
} ) ;
}
Izinkan saya menjelaskan bahwa permintaan EventSource
Asli hanya dapat menjadi permintaan GET
, jadi ketika Anda mendemonstrasikan di sini, Anda akan secara langsung mengajukan pertanyaan di parameter URL
GET
. Jika Anda ingin menggunakan permintaan POST
, umumnya ada dua cara:
Ubah bagian depan dan backends: [Kirim POST
terlebih dahulu dan kemudian GET
POST
untuk mengajukan POST
backend, dan GET
menghasilkan kunci unik berdasarkan pertanyaan dan waktu.
Ubah Front $question = urldecode($_GET['q'] ?? '')
End: [Hanya Kirim Satu POST
] chat.php
back-end tidak perlu diubah secara signifikan. $question = urldecode($_POST['q'] ?? '')
kode yang diberikan dalam EventSource
di bawah ini.
async function fetchAiResponse ( message ) {
try {
const response = await fetch ( "./chat.php" , {
method : "POST" ,
headers : { "Content-Type" : "application/json" } ,
body : JSON . stringify ( { messages : [ { role : "user" , content : message } ] } ) ,
} ) ;
if ( ! response . ok ) {
throw new Error ( response . statusText ) ;
}
const reader = response . body . getReader ( ) ;
const decoder = new TextDecoder ( "utf-8" ) ;
while ( true ) {
const { value , done } = await reader . read ( ) ;
if ( value ) {
const partialResponse = decoder . decode ( value , { stream : true } ) ;
displayMessage ( "assistant" , partialResponse ) ;
}
if ( done ) {
break ;
}
}
} catch ( error ) {
console . error ( "Error fetching AI response:" , error ) ;
displayMessage ( "assistant" , "Error: Failed to fetch AI response." ) ;
}
}
Dalam kode di atas, titik kunci adalah { stream: true }
di const partialResponse = decoder.decode(value, { stream: true })
.
Untuk semua konten balasan yang dikembalikan oleh backend, kita perlu mencetaknya dalam bentuk mesin tik.
Solusi awal adalah untuk segera menampilkannya di halaman setiap kali Anda menerima pengembalian backend. Jadi solusi kemudian diubah untuk menggunakan timer untuk mengimplementasikan pencetakan waktunya, jadi Anda harus memasukkan satu ke dalam array terlebih dahulu untuk mensusunkannya, dan kemudian menjalankannya secara teratur setiap 50 milidetik untuk mencetak satu konten. Kode implementasi spesifik adalah sebagai berikut:
function typingWords ( ) {
if ( contentEnd && contentIdx == typingIdx ) {
clearInterval ( typingTimer ) ;
answerContent = '' ;
answerWords = [ ] ;
answers = [ ] ;
qaIdx += 1 ;
typingIdx = 0 ;
contentIdx = 0 ;
contentEnd = false ;
lastWord = '' ;
lastLastWord = '' ;
input . disabled = false ;
sendButton . disabled = false ;
console . log ( ( new Date ( ) . getTime ( ) ) , 'typing end' ) ;
return ;
}
if ( contentIdx <= typingIdx ) {
return ;
}
if ( typing ) {
return ;
}
typing = true ;
if ( ! answers [ qaIdx ] ) {
answers [ qaIdx ] = document . getElementById ( 'answer-' + qaIdx ) ;
}
const content = answerWords [ typingIdx ] ;
if ( content . indexOf ( '`' ) != - 1 ) {
if ( content . indexOf ( '```' ) != - 1 ) {
codeStart = ! codeStart ;
} else if ( content . indexOf ( '``' ) != - 1 && ( lastWord + content ) . indexOf ( '```' ) != - 1 ) {
codeStart = ! codeStart ;
} else if ( content . indexOf ( '`' ) != - 1 && ( lastLastWord + lastWord + content ) . indexOf ( '```' ) != - 1 ) {
codeStart = ! codeStart ;
}
}
lastLastWord = lastWord ;
lastWord = content ;
answerContent += content ;
answers [ qaIdx ] . innerHTML = marked . parse ( answerContent + ( codeStart ? 'nn```' : '' ) ) ;
typingIdx += 1 ;
typing = false ;
}
Jika Anda mencetak dengan tepat apa itu output, maka ketika Anda mencetak sepotong kode, Anda harus menunggu sampai semua kode selesai sebelum dapat diformat ke dalam blok kode dan kode dapat disorot. Maka pengalaman ini terlalu buruk. Apakah ada cara untuk menyelesaikan masalah ini? Jawabannya ada dalam pertanyaan.
Implementasi spesifik adalah baris kode berikut:
if ( content . indexOf ( '`' ) != - 1 ) {
if ( content . indexOf ( '```' ) != - 1 ) {
codeStart = ! codeStart ;
} else if ( content . indexOf ( '``' ) != - 1 && ( lastWord + content ) . indexOf ( '```' ) != - 1 ) {
codeStart = ! codeStart ;
} else if ( content . indexOf ( '`' ) != - 1 && ( lastLastWord + lastWord + content ) . indexOf ( '```' ) != - 1 ) {
codeStart = ! codeStart ;
}
}
lastLastWord = lastWord ;
lastWord = content ;
answerContent += content ;
answers [ qaIdx ] . innerHTML = marked . parse ( answerContent + ( codeStart ? 'nn```' : '' ) ) ;
Untuk detail lebih lanjut, silakan merujuk ke kode.
BSD 2 Clause