Open Source pure PHP de @qiayue implémente les appels de streaming GPT et le webui d'impression en temps réel frontal.
Mis à jour le 13 avril:
1. La vitesse a été lente récemment car OpenAI a une vitesse limitée pour les comptes gratuits.
2. La limite de vitesse signifie que lors des demandes de streaming, il faut environ 20 secondes pour retourner le premier jeton, et le compte lié à la carte de crédit est d'environ 2 secondes;
/
├─ /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
Répertoire / fichier | illustrer |
---|---|
/ / | Répertoire des racines du programme |
/classe | répertoire de fichiers PHP |
/class/class.chatgpt.php | Classe ChatGPT, utilisée pour traiter les demandes frontales et soumettre des demandes à l'interface OpenAI |
/class/class.dfa.php | Classe DFA pour le remplacement de la somme de mots sensibles |
/class/class.streamhandler.php | Classe StreamHandler, utilisée pour traiter les données renvoyées par OpenAI en temps réel |
/statique | Stockez tous les fichiers statiques requis pour les pages frontales |
/ statique / CSS | Stockez tous les fichiers CSS sur la page frontale |
/static/css/chat.css | Fichier de style de chat de page frontale |
/static/css/monokai-sublime.css | Mettez en surbrillance le fichier de style de thème du code de mise en évidence du plugin |
/ statique / js | Stockez tous les fichiers JS sur la page frontale |
/static/js/chat.js | Interaction de chat frontal Code js |
/static/js/highlight.min.js | Code mettant en évidence la bibliothèque JS |
/static/js/marked.min.js | Bibliothèque JS d'analyse de l'analyse Markdown |
/chat.php | Le fichier d'entrée backend pour les demandes de chat frontal, où le fichier de classe PHP est introduit |
/Index.html | Code HTML de page frontale |
/Readme.md | Fichier de description de l'entrepôt |
/sensitive_words.txt | Fichier de mots sensible, chaque ligne, vous devez collecter vous-même des mots sensibles, vous pouvez également m'ajouter sur WeChat (identique à GitHub ID) pour me trouver. |
Le code de ce projet n'utilise aucun framework, ni introduit une bibliothèque backend tierce.
Les deux seules choses à faire sont de remplir votre propre clé d'API.
Après avoir obtenu le code source, modifiez chat.php
, remplissez la touche API OpenAI et entrez. Pour plus de détails, veuillez consulter:
$ chat = new ChatGPT ([
' api_key ' => '此处需要填入 openai 的 api key ' ,
]);
Si la fonction de détection de mot sensible est activée, vous devez mettre la ligne de mot sensible dans le fichier sensitive_words_sdfdsfvdfs5v56v5dfvdf.txt
.
J'ai ouvert un groupe WeChat et je me suis bienvenu pour rejoindre le groupe pour communiquer:
Dans le backend class.chatgpt.php, utilisez CURL pour lancer une demande à Openai, utilisez CURLOPT_WRITEFUNCTION
de Curl pour définir la fonction de rappel et, en même temps, 'stream' => true
dans le paramètre de demande indique OpenAI pour activer le streaming.
Nous utilisons curl_setopt($ch, CURLOPT_WRITEFUNCTION, [$this->streamHandler, 'callback']);
$this->streamHandler
' callback
']);
Openai renvoie data: {"id":"","object":"","created":1679616251,"model":"","choices":[{"delta":{"content":""},"index":0,"finish_reason":null}]}
choices[0]['delta']['content']
ne peut pas obtenir directement de données comme celle-ci.
De plus, en raison de problèmes de transmission du réseau, les données reçues par callback
à chaque fois n'ont pas nécessairement une seule data: {"key":"value"}
, qui ne peut avoir qu'un demi-morceau, ou plusieurs pièces, ou n et demi.
Nous avons donc ajouté data_buffer
à StreamHandler
pour stocker la moitié des données qui ne peuvent pas être analysées.
Ici, un traitement spécial est effectué sur la base du format de données de retour d'OpenAI, le code spécifique est le suivant:
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 );
}
Nous avons utilisé l'algorithme DFA pour implémenter "DFA"是指“确定性有限自动机”(Deterministic Finite Automaton)
détection sensible DfaFilter(确定有限自动机过滤器)通常是指一种用于文本处理和匹配的算法
mots.
Le code de classe class.dfa.php est écrit en GPT4 et le code d'implémentation spécifique est affiché dans le code source.
Voici une description de la façon de l'utiliser.
$ dfa = new DFA ([
' words_file ' => ' ./sensitive_words_sdfdsfvdfs5v56v5dfvdf.txt ' ,
]);
Remarque spéciale: Le nom de fichier de la chaîne brouillée est spécialement utilisé ici pour empêcher les autres de télécharger des fichiers de mots sensibles.
TRUE
cela, vous pouvez utiliser $dfa->containsSensitiveWords($inputText)
FALSE
$outputText = $dfa->replaceWords($inputText)
*
$inputText
sensitive_words.txt
des mots sensibles.
Si vous ne souhaitez pas activer la détection de mots sensible, commentez les trois phrases suivantes dans chat.php
:
$ dfa = new DFA ([
' words_file ' => ' ./sensitive_words_sdfdsfvdfs5v56v5dfvdf.txt ' ,
]);
$ chat -> set_dfa ( $ dfa );
Si la détection des mots sensibles n'est pas activée, chaque retour d'OpenAI sera retourné à l'avant en temps réel.
Si la détection des mots sensibles est activée, les symboles Newline et Pause [',', '。', ';', '?', '!', '……']
etc. Pour effectuer la segmentation de $outputText = $dfa->replaceWords($inputText)
phrase.
Après avoir activé les mots sensibles, il faut du temps pour charger le fichier de mots sensible.
Par conséquent, si c'est pour votre propre usage, vous ne pouvez pas activer la détection sensible des mots.
Il suffit de regarder les commentaires dans chat.php
le rendra plus clair:
/*
以下几行注释由 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 ' );
Après cela, chaque fois que nous voulons retourner des données à l'avant, utilisez le code suivant:
echo ' data: ' . json_encode ([ ' time ' => date ( ' Y-m-d H:i:s ' ), ' content ' => '答: ' ]). PHP_EOL . PHP_EOL ;
flush ();
Ici, nous définissons un format de données que nous utilisons, qui ne contient que du temps et du contenu.
Notez qu'après transmettre toutes les réponses, nous devons fermer la connexion et nous pouvons utiliser le code suivant:
echo ' retry: 86400000 ' . PHP_EOL ; // 告诉前端如果发生错误,隔多久之后才轮询一次
echo ' event: close ' . PHP_EOL ; // 告诉前端,结束了,该说再见了
echo ' data: Connection closed ' . PHP_EOL . PHP_EOL ; // 告诉前端,连接已关闭
flush ();
Front-end JS permet une demande d'événements sur const eventSource = new EventSource(url);
;.
Après cela, le serveur envoie des données à l'avant dans le format event.data
data: {"kev1":"value1","kev2":"value2"}
JSON.parse(event.data)
{"kev1":"value1","kev2":"value2"}
Le code spécifique se trouve dans la fonction Getanswer, comme indiqué ci-dessous:
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' ) ;
} ) ;
}
Permettez-moi d'expliquer que la demande EventSource
natives ne peut être qu'une demande GET
, donc lorsque vous démontrez ici, vous metterez directement la question dans URL
GET
. Si vous souhaitez utiliser les demandes POST
, il y a généralement deux façons:
Changez le front et les backends: [Envoyez POST
, puis GET
] POST
pour poser les questions du backend, et POST
backend génère une clé GET
en fonction de la question et de l'heure.
Changer le frontal uniquement: $question = urldecode($_GET['q'] ?? '')
Envoyez une seule demande POST
] Le chat.php
back-end n'a pas besoin d'être modifié de manière significative. L'exemple $question = urldecode($_POST['q'] ?? '')
code donné dans EventSource
ci-dessous.
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." ) ;
}
}
Dans le code ci-dessus, le point clé est { stream: true }
dans const partialResponse = decoder.decode(value, { stream: true })
.
Pour tout le contenu de réponse renvoyé par le backend, nous devons l'imprimer sous forme de machine à écrire.
La solution initiale était de l'afficher immédiatement sur la page chaque fois que vous avez reçu le retour du backend. Ainsi, la solution ultérieure a été changée en utilisant une minuterie pour implémenter l'impression chronométrée, vous devez donc mettre celui reçu dans le tableau d'abord pour le mettre en cache, puis l'exécuter régulièrement toutes les 50 millisecondes pour imprimer un contenu. Le code d'implémentation spécifique est le suivant:
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 ;
}
Si vous imprimez exactement ce qui est la sortie, lorsque vous imprimez un morceau de code, vous devez attendre que tout le code soit terminé avant qu'il ne puisse être formaté dans un bloc de code et que le code puisse être mis en surbrillance. Ensuite, cette expérience est trop mauvaise. Existe-t-il un moyen de résoudre ce problème? La réponse est dans la question.
L'implémentation spécifique est les lignes de code suivantes:
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```' : '' ) ) ;
Pour plus de détails, veuillez vous référer au code.
BSD 2-CLAUSE