โอเพนซอร์ส PHP บริสุทธิ์จาก @Qiayue ใช้การโทรแบบสตรีมมิ่ง GPT และการพิมพ์แบบเรียลไทม์ WebUI แบบเรียลไทม์
อัปเดต 13 เมษายน:
1. ความเร็วได้ช้าเมื่อเร็ว ๆ นี้เนื่องจาก OpenAI มีความเร็ว จำกัด สำหรับบัญชีฟรี
2. ขีด จำกัด ความเร็วหมายความว่าเมื่อมีการสตรีมคำขอใช้เวลาประมาณ 20 วินาทีในการส่งคืนโทเค็นแรกและบัญชีที่ผูกพันกับบัตรเครดิตจะอยู่ที่ประมาณ 2 วินาที
/
├─ /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
ไดเรกทอรี/ไฟล์ | อธิบาย |
---|---|
- | ไดเรกทอรีรากโปรแกรม |
/ระดับ | ไดเรกทอรีไฟล์ PHP |
/class/class.chatgpt.php | คลาส chatgpt ใช้ในการประมวลผลคำขอส่วนหน้าและส่งคำขอไปยังอินเตอร์เฟส openai |
/class/class.dfa.php | คลาส DFA สำหรับการเปลี่ยนการตรวจสอบคำที่ละเอียดอ่อน |
/class/class.streamhandler.php | คลาส StreamHandler ใช้ในการประมวลผลข้อมูลที่ส่งคืนโดย OpenAI ในเวลาจริง |
/แบบคงที่ | จัดเก็บไฟล์คงที่ทั้งหมดที่จำเป็นสำหรับหน้าส่วนหน้า |
/Static/CSS | จัดเก็บไฟล์ CSS ทั้งหมดในหน้า front-end |
/static/css/chat.css | ไฟล์สไตล์การแชทหน้าหน้า |
/static/css/monokai-sublime.css | ไฮไลต์ไฟล์สไตล์ธีมสำหรับรหัสไฮไลต์ปลั๊กอิน |
/static/js | จัดเก็บไฟล์ js ทั้งหมดในหน้า front-end |
/static/js/chat.js | การโต้ตอบแชท front-end JS รหัส |
/static/js/highlight.min.js | รหัสที่เน้นไลบรารี JS |
/static/js/marked.min.js | Markdown Parsing JS Library |
/chat.php | ไฟล์รายการแบ็กเอนด์สำหรับคำขอแชท front-end ที่มีการแนะนำไฟล์คลาส PHP |
/index.html | หน้า front-end รหัส html |
/readme.md | ไฟล์คำอธิบายคลังสินค้า |
/sensitive_words.txt | ไฟล์คำที่ละเอียดอ่อนแต่ละบรรทัดคุณต้องรวบรวมคำที่ละเอียดอ่อนด้วยตัวคุณเองคุณยังสามารถเพิ่มฉันใน WeChat (เหมือนกับ GitHub ID) เพื่อค้นหาฉัน |
รหัสของโครงการนี้ไม่ได้ใช้เฟรมเวิร์กใด ๆ และไม่แนะนำห้องสมุดแบ็คเอนด์ของบุคคลที่สาม
สองสิ่งที่ต้องทำคือเติมคีย์ API ของคุณเอง
หลังจากได้รับซอร์สโค้ดแก้ไข chat.php
กรอกข้อมูลในคีย์ OpenAI API และเข้าไปในรายละเอียดโปรดดู:
$ chat = new ChatGPT ([
' api_key ' => '此处需要填入 openai 的 api key ' ,
]);
หากเปิดใช้งานฟังก์ชั่นการตรวจจับคำที่ละเอียดอ่อนคุณจะต้องใส่บรรทัดคำที่ละเอียดอ่อนลงใน sensitive_words_sdfdsfvdfs5v56v5dfvdf.txt
ฉันเปิดกลุ่ม WeChat และยินดีต้อนรับเข้าร่วมกลุ่มเพื่อสื่อสาร:
ในคลาส Backend.chatgpt.php ให้ใช้ Curl เพื่อเริ่มคำขอไปยัง OpenAI ให้ใช้ CURLOPT_WRITEFUNCTION
ของ Curl เพื่อตั้งค่าฟังก์ชั่นการโทรกลับและในเวลาเดียวกัน 'stream' => true
ในพารามิเตอร์การร้องขอ
เราใช้ curl_setopt($ch, CURLOPT_WRITEFUNCTION, [$this->streamHandler, 'callback']);
$this->streamHandler
' callback
']);
Openai จะส่งคืน data: {"id":"","object":"","created":1679616251,"model":"","choices":[{"delta":{"content":""},"index":0,"finish_reason":null}]}
choices[0]['delta']['content']
และไม่สามารถรับข้อมูลได้โดยตรงเช่นนี้
นอกจากนี้เนื่องจากปัญหาการส่งผ่านเครือข่ายข้อมูลที่ได้รับจากฟังก์ชั่น callback
ในแต่ละครั้งไม่จำเป็นต้องมี data: {"key":"value"}
ซึ่งอาจมีเพียงครึ่งชิ้นหรือหลายชิ้นหรือ n และครึ่งชิ้น
ดังนั้นเราจึงเพิ่มแอตทริบิวต์ data_buffer
ในคลาส StreamHandler
เพื่อจัดเก็บครึ่งหนึ่งของข้อมูลที่ไม่สามารถแยกวิเคราะห์ได้
ที่นี่การประมวลผลพิเศษบางอย่างจะดำเนินการตามรูปแบบข้อมูลการส่งคืนของ OpenAI รหัสเฉพาะมีดังนี้:
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 );
}
เราใช้อัลกอริทึม DFA เพื่อใช้ DfaFilter(确定有限自动机过滤器)通常是指一种用于文本处理和匹配的算法
ตรวจจับคำ "DFA"是指“确定性有限自动机”(Deterministic Finite Automaton)
ละเอียดอ่อน
รหัสคลาส class.dfa.php ถูกเขียนใน GPT4 และรหัสการใช้งานเฉพาะจะแสดงในซอร์สโค้ด
นี่คือคำอธิบายของวิธีการใช้งาน
$ dfa = new DFA ([
' words_file ' => ' ./sensitive_words_sdfdsfvdfs5v56v5dfvdf.txt ' ,
]);
หมายเหตุพิเศษ: ชื่อไฟล์ของสตริงที่อ่านไม่ออกถูกใช้เป็นพิเศษที่นี่เพื่อป้องกันไม่ให้ผู้อื่นดาวน์โหลดไฟล์คำที่ละเอียดอ่อน
หลังจากนั้นคุณสามารถใช้ $dfa->containsSensitiveWords($inputText)
$outputText = $dfa->replaceWords($inputText)
ตรวจสอบว่า $inputText
sensitive_words.txt
คำที่ละเอียด TRUE
*
FALSE
หากคุณไม่ต้องการเปิดใช้งานการตรวจจับคำที่ละเอียดอ่อนให้แสดงความคิดเห็นสามประโยคต่อไปนี้ใน chat.php
:
$ dfa = new DFA ([
' words_file ' => ' ./sensitive_words_sdfdsfvdfs5v56v5dfvdf.txt ' ,
]);
$ chat -> set_dfa ( $ dfa );
หากไม่ได้เปิดใช้งานการตรวจจับคำที่ละเอียดอ่อนการกลับมาของ OpenAI แต่ละครั้งจะถูกส่งกลับไปยังส่วนหน้าแบบเรียลไทม์
หากเปิดใช้งานการตรวจจับคำที่ละเอียดอ่อนสัญลักษณ์ใหม่และการหยุดชั่วคราว [',', '。', ';', '?', '!', '……']
$outputText = $dfa->replaceWords($inputText)
[',', '。', ';', '?', '!', '……']
ฯลฯ เพื่อทำการแบ่งส่วนประโยค
หลังจากเปิดคำที่ละเอียดอ่อนแล้วมันต้องใช้เวลาในการโหลดไฟล์คำที่ละเอียดอ่อน
ดังนั้นหากเป็นการใช้งานของคุณเองคุณไม่สามารถเปิดใช้งานการตรวจจับคำที่ละเอียดอ่อนได้
เพียงแค่ดูความคิดเห็นใน chat.php
จะทำให้ชัดเจนขึ้น:
/*
以下几行注释由 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 ' );
หลังจากนั้นทุกครั้งที่เราต้องการส่งคืนข้อมูลไปยังส่วนหน้าให้ใช้รหัสต่อไปนี้:
echo ' data: ' . json_encode ([ ' time ' => date ( ' Y-m-d H:i:s ' ), ' content ' => '答: ' ]). PHP_EOL . PHP_EOL ;
flush ();
ที่นี่เรากำหนดรูปแบบข้อมูลที่เราใช้ซึ่งมีเวลาและเนื้อหาเท่านั้น
โปรดทราบว่าหลังจากส่งคำตอบทั้งหมดเราต้องปิดการเชื่อมต่อและเราสามารถใช้รหัสต่อไปนี้:
echo ' retry: 86400000 ' . PHP_EOL ; // 告诉前端如果发生错误,隔多久之后才轮询一次
echo ' event: close ' . PHP_EOL ; // 告诉前端,结束了,该说再见了
echo ' data: Connection closed ' . PHP_EOL . PHP_EOL ; // 告诉前端,连接已关闭
flush ();
Front-end JS เปิดใช้งานการร้องขอ EventsOrce ผ่าน const eventSource = new EventSource(url);
;
หลังจากนั้นเซิร์ฟเวอร์จะส่งข้อมูลไปยังส่วนหน้าในรูป JSON.parse(event.data)
ของ event.data
data: {"kev1":"value1","kev2":"value2"}
{"kev1":"value1","kev2":"value2"}
รหัสเฉพาะอยู่ในฟังก์ชัน GetAnswer ดังที่แสดงด้านล่าง:
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' ) ;
} ) ;
}
ให้ฉันอธิบายว่าคำขอ EventSource
ดั้งเดิมสามารถเป็นคำขอ GET
เท่านั้นดังนั้นเมื่อคุณแสดงที่นี่คุณจะใส่คำถามโดยตรงในพารามิเตอร์ URL
ของ GET
หากคุณต้องการใช้คำขอ POST
โดยทั่วไปมีสองวิธี:
เปลี่ยนด้านหน้าและแบ็กเอนด์ด้วยกัน: [ส่ง POST
ก่อนแล้ว GET
] ใช้ POST
POST
เพื่อถามคำถามแบ็กเอนด์และแบ็กเอนด์สร้างคีย์ที่ GET
ซ้ำกันตามคำถามและเวลา
เปลี่ยน Front-end เท่านั้น: คุณต้องเปลี่ยนรหัสหลังหนึ่ง POST
เท่านั้นคุณต้องเปลี่ยนคำถาม $ chat.php
($ $question = urldecode($_POST['q'] ?? '')
$question = urldecode($_GET['q'] ?? '')
ตัวอย่างรหัสที่ให้ไว้ใน EventSource
ด้านล่าง
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." ) ;
}
}
ในรหัสด้านบนจุดสำคัญคือ { stream: true }
ใน const partialResponse = decoder.decode(value, { stream: true })
สำหรับเนื้อหาการตอบกลับทั้งหมดที่ส่งคืนโดยแบ็กเอนด์เราต้องพิมพ์ออกมาในรูปแบบเครื่องพิมพ์ดีด
โซลูชันเริ่มต้นคือการแสดงบนหน้าเว็บทันทีทุกครั้งที่คุณได้รับผลตอบแทนแบ็กเอนด์ ดังนั้นวิธีการแก้ปัญหาในภายหลังจึงถูกเปลี่ยนเป็นตัวจับเวลาเพื่อใช้การพิมพ์ที่กำหนดเวลาดังนั้นคุณต้องใส่สิ่งที่ได้รับลงในอาร์เรย์ก่อนเพื่อแคชแล้วดำเนินการอย่างสม่ำเสมอทุก ๆ 50 มิลลิวินาทีเพื่อพิมพ์เนื้อหาหนึ่งเนื้อหา รหัสการใช้งานเฉพาะมีดังนี้:
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 ;
}
หากคุณพิมพ์สิ่งที่เป็นเอาท์พุทแล้วเมื่อคุณพิมพ์รหัสชิ้นหนึ่งคุณต้องรอจนกว่ารหัสทั้งหมดจะเสร็จสิ้นก่อนที่จะสามารถจัดรูปแบบลงในบล็อกโค้ดและสามารถเน้นโค้ดได้ จากนั้นประสบการณ์นี้ก็แย่เกินไป มีวิธีแก้ปัญหานี้บ้างไหม? คำตอบอยู่ในคำถาม
การใช้งานเฉพาะคือบรรทัดของรหัสต่อไปนี้:
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```' : '' ) ) ;
สำหรับรายละเอียดเพิ่มเติมโปรดดูรหัส
BSD 2 ข้อ