Pure PHP Open Source de @qiayue implementa llamadas de transmisión de GPT e impresión en tiempo real en tiempo real webui.
Actualizado el 13 de abril:
1. La velocidad ha sido lenta recientemente porque OpenAi tiene una velocidad limitada para las cuentas gratuitas.
2. El límite de velocidad significa que al transmitir las solicitudes, tarda unos 20 segundos en devolver el primer token, y la cuenta vinculada a la tarjeta de crédito es de aproximadamente 2 segundos;
/
├─ /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
| Directorio/archivo | ilustrar |
|---|---|
| / | Directorio de raíz del programa |
| /clase | directorio de archivos php |
| /class/class.chatgpt.php | Clase CHATGPT, utilizada para procesar solicitudes front-end y enviar solicitudes a la interfaz Operai |
| /class/class.dfa.php | Clase DFA para reemplazo de suma de verificación de palabras sensible |
| /class/class.streamhandler.php | Clase StreamHandler, utilizada para procesar los datos devueltos por OpenAI en tiempo real |
| /estático | Almacene todos los archivos estáticos necesarios para las páginas front-end |
| /estática/CSS | Almacene todos los archivos CSS en la página front-end |
| /static/css/chat.css | Archivo de estilo de chat de la página front-end |
| /static/css/monokai-sublime.css | resaltar el archivo de estilo de tema para el código de resaltar el complemento |
| /estático/js | Almacene todos los archivos JS en la página front-end |
| /static/js/chat.js | Interacción de chat front-end código JS |
| /static/js/highlight.min.js | Biblioteca JS de resaltado de código |
| /static/js/marked.min.js | Biblioteca de Parsing JS de Markdown |
| /chat.php | El archivo de entrada de backend para las solicitudes de chat front-end, donde se introduce el archivo de clase PHP |
| /index.html | Código HTML de la página frontal |
| /Readme.md | Archivo de descripción del almacén |
| /sensitive_words.txt | Archivo de palabras sensible, cada línea, debe recopilar palabras sensibles usted mismo, también puede agregarme en WeChat (igual que GitHub ID) para encontrarme. |
El código de este proyecto no utiliza ningún marco, ni introduce bibliotecas de back-end de terceros.
Las únicas dos cosas que deben hacer es completar su propia clave API.
Después de obtener el código fuente, modifique chat.php , complete la tecla API de OpenAI y vaya. Para más detalles, consulte:
$ chat = new ChatGPT ([
' api_key ' => '此处需要填入 openai 的 api key ' ,
]); Si la función de detección de palabras confidencial está habilitada, debe colocar la línea de palabras sensible en el archivo sensitive_words_sdfdsfvdfs5v56v5dfvdf.txt .
Abrí un grupo WeChat y bienvenido a unirme al grupo para comunicarse:
En el backend class.chatgpt.php, use Curl para iniciar una solicitud para OpenAI, use CURLOPT_WRITEFUNCTION para establecer la función de devolución de llamada y, al mismo tiempo, 'stream' => true en el parámetro de solicitud le dice a OpenAI para habilitar la transmisión.
Usamos curl_setopt($ch, CURLOPT_WRITEFUNCTION, [$this->streamHandler, 'callback']); $this->streamHandler ' callback ']);
OpenAi devolverá data: {"id":"","object":"","created":1679616251,"model":"","choices":[{"delta":{"content":""},"index":0,"finish_reason":null}]} format string, y la respuesta que necesitamos es en choices[0]['delta']['content'] . juicios y no pueden obtener directamente datos como este.
Además, debido a los problemas de transmisión de red, los datos recibidos por callback cada vez no tienen necesariamente un solo data: {"key":"value"} , que solo puede tener media pieza, o múltiples piezas, o n y media piezas.
Así que agregamos data_buffer a StreamHandler para almacenar la mitad de los datos que no se pueden analizar.
Aquí, se realiza un procesamiento especial en función del formato de datos de devolución de OpenAI, el código específico es el siguiente:
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 );
} Utilizamos "DFA"是指“确定性有限自动机”(Deterministic Finite Automaton) algoritmo DFA para implementar DfaFilter(确定有限自动机过滤器)通常是指一种用于文本处理和匹配的算法detección de palabras confidencial.
El código de clase class.dfa.php está escrito en GPT4, y el código de implementación específico se muestra en el código fuente.
Aquí hay una descripción de cómo usarlo.
$ dfa = new DFA ([
' words_file ' => ' ./sensitive_words_sdfdsfvdfs5v56v5dfvdf.txt ' ,
]);Nota especial: El nombre del archivo de la cadena confusión se usa especialmente aquí para evitar que otros descarguen archivos de palabras confidenciales.
Después FALSE eso, puede usar $dfa->containsSensitiveWords($inputText) TRUE determinar si $inputText contiene palabras confidenciales ( * sensitive_words.txt $outputText = $dfa->replaceWords($inputText) para reemplazar las palabras sensibles especificadas.
Si no desea habilitar la detección de palabras confidenciales, comente las siguientes tres oraciones en chat.php :
$ dfa = new DFA ([
' words_file ' => ' ./sensitive_words_sdfdsfvdfs5v56v5dfvdf.txt ' ,
]);
$ chat -> set_dfa ( $ dfa );Si la detección de palabras sensible no está habilitada, cada devolución de OpenAI se devolverá a la parte delantera en tiempo real.
Si la detección de palabras sensible está habilitada, la nueva línea y los símbolos de pausa [',', '。', ';', '?', '!', '……'] $outputText = $dfa->replaceWords($inputText) [',', '。', ';', '?', '!', '……'] etc. para realizar la segmentación de oraciones.
Después de activar palabras sensibles, lleva tiempo cargar el archivo de palabras sensible.
Por lo tanto, si es para su propio uso, no puede habilitar la detección de palabras confidencial.
Solo mirando los comentarios en chat.php lo hará más claro:
/*
以下几行注释由 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 ' );Después de eso, cada vez que queremos devolver los datos al front-end, use el siguiente código:
echo ' data: ' . json_encode ([ ' time ' => date ( ' Y-m-d H:i:s ' ), ' content ' => '答: ' ]). PHP_EOL . PHP_EOL ;
flush ();Aquí definimos un formato de datos que usamos, que solo contiene tiempo y contenido.
Tenga en cuenta que después de que se transmitan todas las respuestas, necesitamos cerrar la conexión y podemos usar el siguiente código:
echo ' retry: 86400000 ' . PHP_EOL ; // 告诉前端如果发生错误,隔多久之后才轮询一次
echo ' event: close ' . PHP_EOL ; // 告诉前端,结束了,该说再见了
echo ' data: Connection closed ' . PHP_EOL . PHP_EOL ; // 告诉前端,连接已关闭
flush (); Front-end JS habilita una solicitud de EventSource a través de const eventSource = new EventSource(url); ;.
Después de eso, el servidor envía datos al frente en el formato JSON.parse(event.data) event.data data: {"kev1":"value1","kev2":"value2"} {"kev1":"value1","kev2":"value2"}
El código específico está en la función GetAnswer, como se muestra a continuación:
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' ) ;
} ) ;
} Permítanme explicar que la solicitud Native EventSource solo puede ser una solicitud GET , por lo que cuando demuestre aquí, se pondrá directamente la pregunta en URL del GET . Si desea usar solicitudes POST , generalmente hay dos maneras:
Cambie el frente y el retroceso: [Envíe POST primero y luego GET ] POST para hacer las preguntas de back -end, y POST backend genera una clave GET basada en la pregunta y el tiempo.
Cambie solo el front-end: [solo $question = urldecode($_GET['q'] ?? '') una solicitud POST ] El chat.php fondo no necesita ser cambiado significativamente. Ejemplo $question = urldecode($_POST['q'] ?? '') código dado en EventSource a continuación.
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." ) ;
}
} En el código anterior, el punto clave es { stream: true } en const partialResponse = decoder.decode(value, { stream: true }) .
Para todo el contenido de respuesta devuelto por el backend, necesitamos imprimirlo en forma de máquina de escribir.
La solución inicial era mostrarlo inmediatamente en la página cada vez que recibió el regreso de backend. Por lo tanto, la solución posterior se cambió a usar un temporizador para implementar la impresión cronometrada, por lo que debe poner el recibido primero en la matriz para almacenarlo en caché, y luego ejecutarlo regularmente cada 50 milisegundos para imprimir un contenido. El código de implementación específico es el siguiente:
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 imprime exactamente lo que es salida, cuando imprime un código, debe esperar hasta que todo el código esté terminado antes de que pueda formatearse en un bloque de código y el código se puede resaltar. Entonces esta experiencia es demasiado mala. ¿Hay alguna forma de resolver este problema? La respuesta está en la pregunta.
La implementación específica son las siguientes líneas de código:
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```' : '' ) ) ;Para más detalles, consulte el código.
BSD 2 Cláusula