Pure PHP Open Source von @Qiyue implementiert GPT-Streaming-Anrufe und Front-End-Echtzeit-Druckwebui.
Aktualisiert am 13. April:
1. Die Geschwindigkeit ist in letzter Zeit langsam, da OpenAI nur eine begrenzte Geschwindigkeit für kostenlose Konten hat.
2. Tempolimit bedeutet, dass es bei Streaming -Anfragen etwa 20 Sekunden dauert, bis das erste Token zurückgibt und das an die Kreditkarte gebundene Konto etwa 2 Sekunden beträgt.
/
├─ /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
Verzeichnis/Datei | veranschaulichen |
---|---|
/ | Programm Root Directory |
/Klasse | PHP -Dateiverzeichnis |
/class/class.chatgpt.php | Chatgpt-Klasse, verwendet, um Front-End-Anfragen zu bearbeiten und Anfragen an die OpenAI-Schnittstelle zu senden |
/class/class.dfa.php | DFA -Klasse für sensible Wortprüfungssummen -Ersatz |
/class/class.streamHandler.php | StreamHandler -Klasse, verwendet, um Daten zu verarbeiten, die von OpenAI in Echtzeit zurückgegeben wurden |
/statisch | Speichern Sie alle statischen Dateien, die für Front-End-Seiten erforderlich sind |
/static/CSS | Speichern Sie alle CSS-Dateien auf der Front-End-Seite |
/static/css/chat.css | Front-End-Seiten-Chat-Style-Datei |
/static/css/monokai-sublime.css | Markieren Sie die Themenstil -Datei für den Plugin -Hervorhebungscode |
/static/js | Speichern Sie alle JS-Dateien auf der Front-End-Seite |
/static/js/chat.js | Front-End-Chat-Interaktion JS-Code |
/static/js/highlight.min.js | Code, das die JS -Bibliothek hervorhebt |
/static/js/marked.min.js | Markdown Parsing JS Library |
/chat.php | Die Backend-Eintragungsdatei für Front-End-Chat-Anfragen, hier wird die PHP-Klassendatei eingeführt |
/Idex.html | Front-End-Seite HTML-Code |
/Readme.md | Lagerbeschreibungsdatei |
/Semsitiv_words.txt | Sensitive Word -Datei, jede Zeile, Sie müssen selbst sensible Wörter sammeln. Sie können mich auch auf WeChat (wie GitHub ID) hinzufügen, um mich zu finden. |
Der Code dieses Projekts verwendet weder ein Framework noch ein Drittanbieterbibliotheken.
Die einzigen zwei Dinge, die zu tun sind, ist, Ihren eigenen API -Schlüssel auszufüllen.
Nach Erhalt des Quellcodes ändern Sie chat.php
, füllen Sie den OpenAI -API -Schlüssel ein und finden Sie in Informationen. Weitere Informationen finden Sie unter:
$ chat = new ChatGPT ([
' api_key ' => '此处需要填入 openai 的 api key ' ,
]);
Wenn die Funktion der sensiblen Worterkennung aktiviert ist, müssen Sie die empfindliche Wortzeile in die Datei sensitive_words_sdfdsfvdfs5v56v5dfvdf.txt
einfügen.
Ich eröffnete eine Wechat -Gruppe und willkommen, der Gruppe beizutreten, um zu kommunizieren:
Verwenden Sie in der Backend Class.chatgpt.php CURL, um eine Anfrage zum Öffnen zu initiieren, verwenden Sie die Curl CURLOPT_WRITEFUNCTION
, um die Rückruffunktion festzulegen, und gleichzeitig fordert 'stream' => true
in dem Anforderungsparameter in dem Anforderungsparameter das Streaming aktiviert.
Wir verwenden curl_setopt($ch, CURLOPT_WRITEFUNCTION, [$this->streamHandler, 'callback']);
$this->streamHandler
' callback
'];
OpenAI gibt data: {"id":"","object":"","created":1679616251,"model":"","choices":[{"delta":{"content":""},"index":0,"finish_reason":null}]}
choices[0]['delta']['content']
. Ments und können keine direkten Daten erhalten.
Darüber hinaus enthält die von callback
empfangenen Daten aufgrund von Netzwerkübertragungsproblemen nicht unbedingt nur ein data: {"key":"value"}
, das möglicherweise nur ein halbes Stück oder mehrere Teile oder neinhalb Teile hat.
Daher haben wir StreamHandler
-Klasse data_buffer
-Attribut hinzugefügt, um die Hälfte der Daten zu speichern, die nicht analysiert werden können.
Hier erfolgt eine spezielle Verarbeitung basierend auf dem Rückgabedatenformat von OpenAI wie folgt: Der spezifische Code ist wie folgt:
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 );
}
Wir haben den DFA -Algorithmus verwendet "DFA"是指“确定性有限自动机”(Deterministic Finite Automaton)
um DfaFilter(确定有限自动机过滤器)通常是指一种用于文本处理和匹配的算法
sensible Worterkennung zu implementieren.
Der Klassencode der class.dfa.php ist in GPT4 geschrieben und der spezifische Implementierungscode wird im Quellcode angezeigt.
Hier finden Sie eine Beschreibung, wie Sie es verwenden können.
$ dfa = new DFA ([
' words_file ' => ' ./sensitive_words_sdfdsfvdfs5v56v5dfvdf.txt ' ,
]);
Besonderer Hinweis: Der Dateiname der verstümmelten Zeichenfolge wird hier speziell verwendet, um andere sensible Word -Dateien herunterzuladen.
Danach können Sie FALSE
$dfa->containsSensitiveWords($inputText)
verwenden, um TRUE
sensitive_words.txt
, *
$inputText
$outputText = $dfa->replaceWords($inputText)
Wörter enthält.
Wenn Sie keine sensible Worterkennung aktivieren möchten, kommentieren Sie die folgenden drei Sätze in chat.php
:
$ dfa = new DFA ([
' words_file ' => ' ./sensitive_words_sdfdsfvdfs5v56v5dfvdf.txt ' ,
]);
$ chat -> set_dfa ( $ dfa );
Wenn die sensible Worterkennung nicht aktiviert ist, wird jede Rückgabe von OpenAI in Echtzeit an das vordere Ende zurückgegeben.
Wenn die sensible Worterkennung aktiviert ist, [',', '。', ';', '?', '!', '……']
usw. Um $outputText = $dfa->replaceWords($inputText)
Satzsegmentierung durchzuführen.
Nachdem sensible Wörter eingeschaltet werden, dauert es Zeit, um die sensible Wortdatei zu laden.
Wenn es sich um Ihre eigene Verwendung handelt, können Sie daher keine sensible Worterkennung aktivieren.
Wenn Sie sich nur die Kommentare in chat.php
ansehen, wird es klarer:
/*
以下几行注释由 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 ' );
Danach verwenden Sie jedes Mal, wenn wir Daten an das Front-End zurückgeben möchten, den folgenden Code:
echo ' data: ' . json_encode ([ ' time ' => date ( ' Y-m-d H:i:s ' ), ' content ' => '答: ' ]). PHP_EOL . PHP_EOL ;
flush ();
Hier definieren wir ein Datenformat, das wir selbst verwenden, nur Zeit und Inhalte werden darin platziert, und wir verstehen ohne Erklärung.
Beachten Sie, dass wir nach allen Antworten die Verbindung schließen müssen und den folgenden Code verwenden können:
echo ' retry: 86400000 ' . PHP_EOL ; // 告诉前端如果发生错误,隔多久之后才轮询一次
echo ' event: close ' . PHP_EOL ; // 告诉前端,结束了,该说再见了
echo ' data: Connection closed ' . PHP_EOL . PHP_EOL ; // 告诉前端,连接已关闭
flush ();
Front-End JS ermöglicht eine EventSource-Anfrage über const eventSource = new EventSource(url);
;
event.data
sendet der Server Daten im data: {"kev1":"value1","kev2":"value2"}
JSON.parse(event.data)
{"kev1":"value1","kev2":"value2"}
Der spezifische Code befindet sich in der GetAnswer -Funktion, wie unten gezeigt:
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' ) ;
} ) ;
}
Lassen Sie mich erklären, dass die native EventSource
-Anfrage nur eine GET
-Anfrage sein kann. Wenn Sie hier nachweisen, werden Sie die Frage direkt in URL
-Parameter GET
einstellen. Wenn Sie POST
verwenden möchten, gibt es im Allgemeinen zwei Möglichkeiten:
Ändern Sie die Front- und Backends zusammen: [Senden Sie zuerst POST
GET
, um die Backend POST
zu stellen, und POST
Backend erzeugt eine GET
Taste, die auf der Frage und der Zeit nach dem Frontend zurückgegeben wird.
Ändern Sie nur das Front-End: [ $question = urldecode($_GET['q'] ?? '')
Sie nur eine POST
$question = urldecode($_POST['q'] ?? '')
Der Back-End-Code muss nicht wesentlich geändert werden. chat.php
EventSource
GPT4 unten angegeben.
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." ) ;
}
}
Im obigen Code lautet der Schlüsselpunkt { stream: true }
in const partialResponse = decoder.decode(value, { stream: true })
.
Für alle vom Backend zurückgegebenen Antwortinhalte müssen wir ihn in einer Schreibmaschine ausdrucken.
Die erste Lösung bestand darin, sie jedes Mal auf der Seite zu zeigen, wenn Sie die Backend -Rendite später erhielten. Die spätere Lösung wurde also in die Verwendung eines Timers zum Implementieren des zeitgesteuerten Drucks geändert, sodass Sie das empfangene zuerst in das Array einfügen müssen, um ihn zu richten, und es dann regelmäßig alle 50 Millisekunden ausführen, um einen Inhalt auszudrucken. Der spezifische Implementierungscode lautet wie folgt:
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 ;
}
Wenn Sie genau drucken, was Ausgabe ist, müssen Sie beim Drucken eines Code -Stücks warten, bis der gesamte Code fertig ist, bevor er in einen Codeblock formatiert werden kann und der Code hervorgehoben werden kann. Dann ist diese Erfahrung schade. Gibt es eine Möglichkeit, dieses Problem zu lösen? Die Antwort ist in der Frage.
Die spezifische Implementierung ist die folgenden Codezeilen:
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```' : '' ) ) ;
Weitere Informationen finden Sie im Code.
BSD 2-Klausel