O código aberto puro do PHP da @Qiayue implementa chamadas de streaming GPT e impressão em tempo real do front-end em tempo real Webui.
Atualizado em 13 de abril:
1. A velocidade foi lenta recentemente porque o OpenAI tem uma velocidade limitada para contas gratuitas.
2. Limite de velocidade significa que, ao transmitir solicitações, leva cerca de 20 segundos para retornar o primeiro token, e a conta vinculada ao cartão de crédito é de cerca de 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
| Diretório/arquivo | ilustrar |
|---|---|
| / | Diretório raiz do programa |
| /aula | diretório de arquivos php |
| /class/class.chatgpt.php | Classe ChatGPT, usada para processar solicitações de front-end e enviar solicitações para a interface OpenAI |
| /class/class.dfa.php | Classe DFA para substituição sensível de soma de verificação de palavras |
| /class/class.streamhandler.php | Classe de manipulador de fluxo, usado para processar dados retornados pelo OpenAI em tempo real |
| /estático | Armazene todos os arquivos estáticos necessários para as páginas do front-end |
| /estático/css | Armazene todos os arquivos CSS na página front-end |
| /static/css/chat.css | Arquivo de estilo de bate-papo da página front-end |
| /static/css/monokai-sublime.css | Destaque o arquivo de estilo temático para o código de destaque do plug -in |
| /estático/js | Armazene todos os arquivos JS na página front-end |
| /static/js/chat.js | Código JS de interação de bate-papo front-end |
| /static/js/highlight.min.js | Código destacando a Biblioteca JS |
| /static/js/marked.min.js | Marydown Parsing JS Library |
| /chat.php | O arquivo de entrada de back-end para solicitações de bate-papo front-end, onde o arquivo da classe PHP é introduzido |
| /index.html | Código HTML da página front-end |
| /Readme.md | Arquivo de descrição do armazém |
| /sensitive_words.txt | Arquivo de palavras sensíveis, cada linha, você precisa coletar palavras sensíveis, também pode me adicionar no WeChat (o mesmo que o GitHub ID) para me encontrar. |
O código deste projeto não usa nenhuma estrutura, nem introduz bibliotecas de back-end de terceiros.
As únicas duas coisas a fazer é preencher sua própria chave da API.
Depois de obter o código -fonte, modifique chat.php , preencha a chave da API OpenAI e entre em contato. Para detalhes, consulte:
$ chat = new ChatGPT ([
' api_key ' => '此处需要填入 openai 的 api key ' ,
]); Se a função sensível de detecção de palavras estiver ativada, você precisará colocar a linha de palavras sensíveis no arquivo sensitive_words_sdfdsfvdfs5v56v5dfvdf.txt .
Abri um grupo do WeChat e bem -vindo a ingressar no grupo para comunicar:
Na classe back -end.chatgpt.php, use o CURL para iniciar uma solicitação para o OpenAI, use o CURLOPT_WRITEFUNCTION de Curl para definir a função de retorno de chamada e, ao mesmo tempo, 'stream' => true no parâmetro de solicitação diz ao OpenAI para ativar o streaming.
Usamos curl_setopt($ch, CURLOPT_WRITEFUNCTION, [$this->streamHandler, 'callback']); $this->streamHandler callback
Openai retornará data: {"id":"","object":"","created":1679616251,"model":"","choices":[{"delta":{"content":""},"index":0,"finish_reason":null}]} choices[0]['delta']['content'] que você precisa. e não pode obter diretamente dados como este.
Além disso, devido a problemas de transmissão de rede, os dados recebidos pela função callback cada vez não têm necessariamente apenas um data: {"key":"value"} , que pode ter apenas meia peça ou várias peças, ou n e meio peças.
Por isso, adicionamos data_buffer à classe StreamHandler para armazenar metade dos dados que não podem ser analisados.
Aqui, algum processamento especial é feito com base no formato de dados de retorno do OpenAI, o código específico é o seguinte:
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 o algoritmo DFA para implementar "DFA"是指“确定性有限自动机”(Deterministic Finite Automaton) detecção DfaFilter(确定有限自动机过滤器)通常是指一种用于文本处理和匹配的算法palavras sensíveis.
O código da classe Class.dfa.php está escrito no GPT4 e o código de implementação específico é mostrado no código -fonte.
Aqui está uma descrição de como usá -lo.
$ dfa = new DFA ([
' words_file ' => ' ./sensitive_words_sdfdsfvdfs5v56v5dfvdf.txt ' ,
]);NOTA ESPECIAL: O nome do arquivo da string de Glebled é usado especialmente para impedir que outros baixem arquivos de palavras sensíveis.
Depois disso, você pode usar $dfa->containsSensitiveWords($inputText) $outputText = $dfa->replaceWords($inputText) TRUE * FALSE sensitive_words.txt $inputText contém palavras sensíveis.
Se você não deseja ativar a detecção de palavras sensíveis, comente as três frases a seguir no chat.php :
$ dfa = new DFA ([
' words_file ' => ' ./sensitive_words_sdfdsfvdfs5v56v5dfvdf.txt ' ,
]);
$ chat -> set_dfa ( $ dfa );Se a detecção de palavras sensíveis não estiver ativada, cada devolução do OpenAI será devolvido ao front end em tempo real.
Se a detecção de palavras sensíveis estiver ativada, os símbolos da Newline e Pause [',', '。', ';', '?', '!', '……'] etc. Para executar $outputText = $dfa->replaceWords($inputText) segmentação da frase.
Depois de ativar as palavras sensíveis, leva tempo para carregar o arquivo de palavras sensíveis.
Portanto, se for o seu próprio uso, você não pode ativar a detecção de palavras sensíveis.
Só de olhar para os comentários no chat.php deixará mais 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 ' );Depois disso, toda vez que queremos retornar dados ao front-end, use o seguinte código:
echo ' data: ' . json_encode ([ ' time ' => date ( ' Y-m-d H:i:s ' ), ' content ' => '答: ' ]). PHP_EOL . PHP_EOL ;
flush ();Aqui, definimos um formato de dados que usamos, que contém apenas tempo e conteúdo.
Observe que, depois que todas as respostas forem transmitidas, precisamos fechar a conexão e podemos usar o seguinte código:
echo ' retry: 86400000 ' . PHP_EOL ; // 告诉前端如果发生错误,隔多久之后才轮询一次
echo ' event: close ' . PHP_EOL ; // 告诉前端,结束了,该说再见了
echo ' data: Connection closed ' . PHP_EOL . PHP_EOL ; // 告诉前端,连接已关闭
flush (); O JS front-end permite uma solicitação de origem de eventos através const eventSource = new EventSource(url); ;.
Depois event.data , o servidor envia dados para o front end no formato JSON.parse(event.data) data: {"kev1":"value1","kev2":"value2"} {"kev1":"value1","kev2":"value2"}
O código específico está na função GetAnswer, como mostrado abaixo:
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' ) ;
} ) ;
} Deixe -me explicar que a solicitação Native EventSource pode ser apenas uma GET , portanto, quando você demonstrar aqui, você colocará diretamente a pergunta no parâmetro URL GET . Se você deseja usar solicitações POST , geralmente existem duas maneiras:
Altere os back -end de back -end: [envie POST primeiro e, em seguida, GET POST uso POST para fazer as perguntas do back -end, e o GET -end gera uma chave única com base na pergunta e no tempo.
Altere o front $question = urldecode($_GET['q'] ?? '') end apenas: [Envie apenas uma solicitação $question = urldecode($_POST['q'] ?? '') POST ] O chat.php back-end não precisa ser alterado significativamente. O exemplo de código fornecido no EventSource abaixo.
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." ) ;
}
} No código acima, o ponto de chave é { stream: true } em const partialResponse = decoder.decode(value, { stream: true }) .
Para todo o conteúdo de resposta retornado pelo back -end, precisamos imprimi -lo em um formulário de máquina de escrever.
A solução inicial foi exibi -lo imediatamente na página toda vez que você recebe o retorno do back -end. Portanto, a solução posterior foi alterada para usar um cronômetro para implementar a impressão cronometrada, então você precisa colocar o recebido na matriz primeiro para armazená -lo em cache e depois executá -lo regularmente a cada 50 milissegundos para imprimir um conteúdo. O código de implementação específico é o seguinte:
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 ;
}Se você imprimir exatamente o que é saída, quando você estiver imprimindo um pedaço de código, precisará esperar até que todo o código seja concluído antes que ele possa ser formatado em um bloco de código e o código poderá ser destacado. Então essa experiência é muito ruim. Existe alguma maneira de resolver esse problema? A resposta está na pergunta.
A implementação específica são as seguintes linhas 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 mais detalhes, consulte o código.
BSD 2-cláusula