@QiAyue의 순수한 PHP 오픈 소스는 GPT 스트리밍 통화 및 프론트 엔드 실시간 인쇄 WebUI를 구현합니다.
4 월 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
디렉토리/파일 | 설명 |
---|---|
/ | 프로그램 루트 디렉토리 |
/수업 | PHP 파일 디렉토리 |
/class/class.chatgpt.php | ChatGpt 클래스, 프론트 엔드 요청을 처리하고 OpenAI 인터페이스에 요청을 제출하는 데 사용됩니다. |
/class/class.dfa.php | 민감한 단어 체크섬 교체를위한 DFA 클래스 |
/class/class.streamhandler.php | OpenAI가 실시간으로 반환 한 데이터를 처리하는 데 사용되는 StreamHandler 클래스 |
/공전 | 프론트 엔드 페이지에 필요한 모든 정적 파일을 저장하십시오 |
/정적/CSS | 프론트 엔드 페이지에 모든 CSS 파일을 저장하십시오 |
/static/css/chat.css | 프론트 엔드 페이지 채팅 스타일 파일 |
/static/css/monokai-sublime.css | 플러그인 강조 코드의 테마 스타일 파일 강조 표시 |
/정적/js | 프론트 엔드 페이지에 모든 JS 파일을 저장하십시오 |
/static/js/chat.js | 프론트 엔드 채팅 상호 작용 JS 코드 |
/static/js/highlight.min.js | JS 라이브러리를 강조 표시합니다 |
/static/js/marked.min.js | Markdown Parsing JS 라이브러리 |
/chat.php | PHP 클래스 파일이 소개되는 프론트 엔드 채팅 요청에 대한 백엔드 항목 파일 |
/index.html | 프론트 엔드 페이지 HTML 코드 |
/Readme.md | 창고 설명 파일 |
/sensitive_words.txt | 민감한 단어 파일, 각 줄, 민감한 단어를 직접 수집해야합니다. wechat (github id와 동일)에 나를 추가 할 수도 있습니다. |
이 프로젝트의 코드는 프레임 워크를 사용하지 않으며, 프론트 엔드는 코드 하이라이트 하이라이트와 마크 다운 라이브러리가 프로젝트에 다운로드 된 후에도 소개합니다.
해야 할 유일한 일은 자신의 API 키를 채우는 것입니다.
소스 코드를 얻은 후 chat.php
수정하고 OpenAI API 키를 채우고 들어갑니다. 자세한 내용은 다음을 참조하십시오.
$ chat = new ChatGPT ([
' api_key ' => '此处需要填入 openai 的 api key ' ,
]);
민감한 워드 감지 함수가 활성화되면 민감한 단어 줄을 sensitive_words_sdfdsfvdfs5v56v5dfvdf.txt
에 넣어야합니다.
나는 WeChat 그룹을 열고 그룹에 가입하여 의사 소통을 환영합니다.
Backend class.chatgpt.php에서 Curl을 사용하여 OpenAI 요청을 시작하고 Curl의 CURLOPT_WRITEFUNCTION
사용하여 콜백 함수를 설정하고 동시에 'stream' => true
는 OpenAI에 스트리밍을 활성화하도록 지시합니다.
curl_setopt($ch, CURLOPT_WRITEFUNCTION, [$this->streamHandler, 'callback']);
사용하여 curl_setopt를 통해 OpenAi가 반환하는 데이터를 처리합니다 ($ ch, curlopt_writefunction $this->streamHandler
' callback
'];
OpenAi는 data: {"id":"","object":"","created":1679616251,"model":"","choices":[{"delta":{"content":""},"index":0,"finish_reason":null}]}
format은 choices[0]['delta']['content']
에 대한 답변입니다 이와 같은 데이터를 직접 얻을 수 없습니다.
또한 네트워크 전송 문제로 인해 callback
함수로 수신 된 데이터마다 data: {"key":"value"}
는 반 조각 또는 여러 조각 만 가질 수 있습니다.
따라서 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 );
}
Chatgpt에 따르면 DFA 알고리즘을 사용하여 "DFA"是指“确定性有限自动机”(Deterministic Finite Automaton)
DfaFilter(确定有限自动机过滤器)通常是指一种用于文本处理和匹配的算法
.
class.dfa.php 클래스 코드는 GPT4로 작성되었으며 특정 구현 코드는 소스 코드에 표시됩니다.
다음은 DFA 인스턴스를 만드는 방법에 대한 설명입니다.
$ dfa = new DFA ([
' words_file ' => ' ./sensitive_words_sdfdsfvdfs5v56v5dfvdf.txt ' ,
]);
특별 참고 : Barled String의 파일 이름은 여기에 민감한 Word 파일을 다운로드하지 못하게합니다.
그런 다음 $dfa->containsSensitiveWords($inputText)
*
$inputText
$outputText = $dfa->replaceWords($inputText)
민감한 단어가 포함되어 TRUE
sensitive_words.txt
FALSE
.
민감한 단어 감지를 활성화하지 않으려면 chat.php
에서 다음 세 문장을 주석하십시오.
$ dfa = new DFA ([
' words_file ' => ' ./sensitive_words_sdfdsfvdfs5v56v5dfvdf.txt ' ,
]);
$ chat -> set_dfa ( $ dfa );
민감한 단어 감지가 활성화되지 않으면 OpenAI의 각 반환은 실시간으로 프론트 엔드로 반환됩니다.
민감한 단어 감지가 활성화되면 Newline 및 Pause Symbols [',', '。', ';', '?', '!', '……']
등. 각 문장은 $outputText = $dfa->replaceWords($inputText)
사용하여 전체 문장을 프론트 엔드로 반환합니다.
민감한 단어를 켜면 민감한 단어 파일이 감지 될 때마다 단어별로 문장으로 문장을 감지하는 데 시간이 걸립니다.
따라서 자신의 용도로 사용되는 경우 도메인 이름과 보안을 보호하기 위해 민감한 단어 감지를 가능하게 할 수 없습니다.
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 ();
프론트 엔드 JS는 const eventSource = new EventSource(url);
를 통해 이벤트 소스 요청을 가능하게합니다.
그런 다음 서버는 데이터 형식으로 데이터를 프론트 엔드로 보냅니다 {"kev1":"value1","kev2":"value2"}
data: {"kev1":"value1","kev2":"value2"}
는 eventsource의 JSON.parse(event.data)
콜백 event.data
에서 JSON 데이터를 얻을 수 있습니다.
특정 코드는 다음과 같이 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' ) ;
} ) ;
}
기본 EventSource
요청은 GET
요청 일 수 있다고 설명하겠습니다. 여기에서 시연 할 때 GET
의 URL
매개 변수에 질문을 직접 적용하게됩니다. POST
요청을 사용하려면 일반적으로 두 가지 방법이 있습니다.
전면 및 백엔드를 함께 변경하십시오. [먼저 POST
보내 GET
POST
POST
사용하여 GET
가 질문과 시간에 따라 고유 한 키를 생성합니다.
프론트 엔드 만 변경하십시오. [ POST
요청 만 보내십시오] 백엔드 코드는 $ quest = chat.php
($ $question = urldecode($_POST['q'] ?? '')
$question = urldecode($_GET['q'] ?? '')
에서만 변경할 필요가 없습니다 아래 GPT4에 주어진 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." ) ;
}
}
위의 코드에서 핵심 사항은 const partialResponse = decoder.decode(value, { stream: true })
의 { 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```' : '' ) ) ;
자세한 내용은 코드에 대한 궁금한 점이 있으면 WeChat (Github ID와 동일)를 추가하십시오.
BSD 2-Clause