Pure PHP с открытым исходным кодом от @Qiayue реализует потоковые вызовы GPT и печать в реальном времени.
Обновлено 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
Каталог/файл | иллюстрировать |
---|---|
/ | Программа ROOT Directory |
/сорт | PHP файловый каталог |
/class/class.chatgpt.php | Класс CHATGPT, используемый для обработки запросов фронта |
/class/class.dfa.php | Класс DFA для чувствительной замены контрольной суммы слов |
/class/class.streamhandler.php | Класс Streamhandler, используемый для обработки данных, возвращаемых OpenAI в режиме реального времени |
/Статический | Храните все статические файлы, необходимые для передних страниц |
/static/css | Храните все файлы CSS на линейной странице |
/static/css/chat.css | Файл стиля чата на переднем конце |
/static/css/monokai-sublime.css | Выделите файл стиля темы для кода выделения плагина |
/static/js | Хранить все файлы JS на странице фронта |
/static/js/chat.js | Взаимодействие в чате в чате JS |
/static/js/highlight.min.js | Код выделяет библиотеку JS |
/static/js/marked.min.js | Оценка Markdown Darsing JS Библиотека |
/chat.php | Файл ввода бэкэнд для запросов в чате в интерфейсе, здесь введен файл класса PHP |
/index.html | Front-End Page HTML-код |
/Readme.md | Файл описания склада |
/sensitive_words.txt | Конфиденциальный файл слов, каждая строка, вам необходимо собирать конфиденциальные слова самостоятельно, вы также можете добавить меня в WeChat (так же, как идентификатор GitHub), чтобы найти меня. |
Код этого проекта не использует какую-либо структуру, и он не представляет каких-либо сторонних бэкэнд-библиотеки.
Единственные две вещи, которые нужно сделать, это заполнить свой собственный ключ API.
После получения исходного кода измените chat.php
.
$ chat = new ChatGPT ([
' api_key ' => '此处需要填入 openai 的 api key ' ,
]);
Если функция обнаружения конфиденциальности включена, вам необходимо поместить конфиденциальную строку Word в файл sensitive_words_sdfdsfvdfs5v56v5dfvdf.txt
.
Я открыл группу WeChat и добро пожаловать, чтобы присоединиться к группе, чтобы общаться:
В Backend Class.chatgpt.php используйте Curl, чтобы инициировать запрос на Openai, используйте CURLOPT_WRITEFUNCTION
для Curlopt_writefunction, чтобы установить функцию обратного вызова, и в то же время 'stream' => true
в параметре запроса сообщает Openai для включения потока.
Мы используем 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}]}
format String, и ответ, который мы нуждаемся в choices[0]['delta']['content']
. Gments и не могут напрямую получить подобные данные.
Кроме того, из -за задач передачи сети данные, полученные функцией 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"是指“确定性有限自动机”(Deterministic Finite Automaton)
использовали алгоритм DFA для реализации конфиденциального обнаружения DfaFilter(确定有限自动机过滤器)通常是指一种用于文本处理和匹配的算法
.
Код класса класса.
Вот описание того, как его использовать.
$ dfa = new DFA ([
' words_file ' => ' ./sensitive_words_sdfdsfvdfs5v56v5dfvdf.txt ' ,
]);
Специальное примечание. Имя файла искаженной строки используется здесь, чтобы другие не использовали другие файлы.
TRUE
FALSE
вы можете использовать $dfa->containsSensitiveWords($inputText)
$outputText = $dfa->replaceWords($inputText)
определить *
содержит ли $inputText
sensitive_words.txt
слова.
Если вы не хотите включать конфиденциальное обнаружение слов, прокомментируйте следующие три предложения в chat.php
:
$ dfa = new DFA ([
' words_file ' => ' ./sensitive_words_sdfdsfvdfs5v56v5dfvdf.txt ' ,
]);
$ chat -> set_dfa ( $ dfa );
Если чувствительное обнаружение слов не включено, каждый возврат OpenaI будет возвращен на передний конец в режиме реального времени.
Если обнаружение конфиденциального слова включено, новая линия и символы паузы [',', '。', ';', '?', '!', '……']
и т $outputText = $dfa->replaceWords($inputText)
Д. Для выполнения сегментации предложения.
После включения конфиденциальных слов требуется время для загрузки конфиденциального файла Word.
Поэтому, если это для вашего собственного использования, вы не можете включить чувствительное обнаружение слов.
Просто посмотреть на комментарии в 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 включает запрос Eventource через const eventSource = new EventSource(url);
;
После этого сервер отправляет данные на переднюю event.data
в формате data: {"kev1":"value1","kev2":"value2"}
JSON.parse(event.data)
{"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' ) ;
} ) ;
}
Позвольте мне объяснить, что запрос Native EventSource
может быть только запросом GET
, поэтому, когда вы демонстрируете здесь, вы напрямую поместите вопрос в параметр URL
GET
. Если вы хотите использовать запросы POST
, обычно есть два способа:
Измените фронт и бэкэнды вместе: [сначала POST
POST
, а затем используйте GET
, чтобы задать POST
бэкэнд, а GET
генерирует уникальный ключ на основе вопроса и времени.
Измените только фронт: [только один 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-CLAUSE