@Qiayueの純粋なPHPオープンソースは、GPTストリーミングコールとフロントエンドのリアルタイム印刷WebUIを実装しています。
4月13日更新:
1. Openaiは、Platform.openai.comでクレジットカードをバインドする速度が限られているため、最近速度が遅くなりました。
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 | フロントエンドリクエストを処理し、OpenAIインターフェイスにリクエストを送信するために使用されるChatGptクラス |
/class/class.dfa.php | 敏感な単語チェックサムの交換用のDFAクラス |
/class/class.streamhandler.php | OpenAIがリアルタイムで返したデータの処理に使用されるStreamHandlerクラス |
/静的 | フロントエンドページに必要なすべての静的ファイルを保存します |
/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 | JSライブラリを解析するマークダウン |
/chat.php | フロントエンドチャットリクエスト用のバックエンドエントリファイル、ここでPHPクラスファイルが導入されています |
/index.html | フロントエンドページHTMLコード |
/readme.md | 倉庫の説明ファイル |
/sensitive_words.txt | 敏感な単語ファイル、各行、あなたは自分で敏感な単語を収集する必要があります。また、私を見つけるためにWeChat(GitHub IDと同じ)に私を追加することもできます。 |
このプロジェクトのコードは、フロントエンドのハイライトハイライトとマークダウンの解析ライブラリをマークすることで、コードを取得した後に直接使用できます。
2つのことは、独自の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']);
$this->streamHandler
callback
Openaiはdata: {"id":"","object":"","created":1679616251,"model":"","choices":[{"delta":{"content":""},"index":0,"finish_reason":null}]}
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 );
}
DFAアルゴリズムを使用して、ChATGPTによると、 "DFA"是指“确定性有限自动机”(Deterministic Finite Automaton)
、 DfaFilter(确定有限自动机过滤器)通常是指一种用于文本处理和匹配的算法
。
class.dfa.phpクラスコードはGPT4で記述されており、特定の実装コードがソースコードに表示されます。
DFAインスタンスを作成する方法の説明は、次のことです。
$ dfa = new DFA ([
' words_file ' => ' ./sensitive_words_sdfdsfvdfs5v56v5dfvdf.txt ' ,
]);
特別なメモ:ここでは、展開後に他の人が繊細なファイル名をダウンロードしないように、ここでは特別に使用されています。
その後、 $dfa->containsSensitiveWords($inputText)
FALSE
使用して、 $outputText = $dfa->replaceWords($inputText)
$inputText
敏感な単語を含むか*
かをsensitive_words.txt
できますTRUE
敏感な単語検出を有効にしたくない場合は、 chat.php
で次の3つの文章をコメントしてください。
$ dfa = new DFA ([
' words_file ' => ' ./sensitive_words_sdfdsfvdfs5v56v5dfvdf.txt ' ,
]);
$ chat -> set_dfa ( $ dfa );
敏感な単語検出が有効になっていない場合、OpenAIの各リターンはリアルタイムでフロントエンドに返されます。
敏感な単語検出が有効になっている場合、newline and Pauseシンボル[',', '。', ';', '?', '!', '……']
などのセグメンテーションを実行するために$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);
;。
その後、サーバーはdata: {"kev1":"value1","kev2":"value2"}
{"kev1":"value1","kev2":"value2"}
event.data
を取得し、 JSON.parse(event.data)
を介してJSオブジェクトを取得します。
以下に示すように、特定のコードは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
Requestのみになることができるので、ここでデモを行うと、 GET
のURL
パラメーターに質問を直接配置します。 POST
リクエストを使用したい場合は、通常、次の方法があります。
フロントとバックエンドを一緒に変更します。[最初にPOST
してからGET
] POST
POST
の質問を尋ねると、フロントエンドが取得された後、質問と時間に基づいてGET
のキーが生成されます。
フロントエンドのみ:[1つのPOST
$question = urldecode($_GET['q'] ?? '')
$question = urldecode($_POST['q'] ?? '')
]バックchat.php
コードを大幅に変更する必要があります以下の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ミリ秒ごとに定期的に実行して1つのコンテンツを印刷する必要があります。 特定の実装コードは次のとおりです。
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節