Pure PHP Open Source من QIAYUE ينفذ مكالمات دفق GPT و WebUI للطباعة في الوقت الفعلي.
تم تحديثه في 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 | فئة StreamHandler ، تستخدم لمعالجة البيانات التي يتم إرجاعها بواسطة Openai في الوقت الفعلي |
| /ثابت | تخزين جميع الملفات الثابتة المطلوبة للصفحات الأمامية |
| /ثابت/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 | مكتبة التحليل JS Markdown |
| /chat.php | ملف إدخال الواجهة الخلفية لطلبات الدردشة الأمامية ، حيث يتم تقديم ملف فئة PHP |
| /index.html | رمز HTML في الواجهة الأمامية |
| /readme.md | وصف وصف المستودع |
| /sevitence_words.txt | ملف كلمة حساس ، كل سطر ، تحتاج إلى جمع كلمات حساسة بنفسك ، يمكنك أيضًا إضافتي على WeChat (مثل معرف Github) للعثور علي. |
لا تستخدم رمز هذا المشروع أي إطار ، ولا يقدم أي مكتبات خلفية من طرف ثالث.
شيئان الوحيدان اللذان يجب القيام بهما هو ملء مفتاح API الخاص بك.
بعد الحصول على الرمز المصدر ، تعديل chat.php ، واملأ مفتاح Openai API وادخل. للحصول على التفاصيل ، يرجى الاطلاع على:
$ chat = new ChatGPT ([
' api_key ' => '此处需要填入 openai 的 api key ' ,
]); إذا تم تمكين وظيفة الكشف عن الكلمات الحساسة ، فأنت بحاجة إلى وضع سطر الكلمات الحساسة في ملف sensitive_words_sdfdsfvdfs5v56v5dfvdf.txt .
فتحت مجموعة WeChat ومرحبا بكم للانضمام إلى المجموعة للتواصل:
CURLOPT_WRITEFUNCTION فئة الواجهة 'stream' => true .
نستخدم 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"} ، والتي قد تحتوي فقط على نصف قطعة ، أو قطع متعددة ، أو 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 لتنفيذ DfaFilter(确定有限自动机过滤器)通常是指一种用于文本处理和匹配的算法"DFA"是指“确定性有限自动机”(Deterministic Finite Automaton) الكلمات الحساسة.
يتم كتابة رمز فئة class.dfa.php في GPT4 ، ويتم عرض رمز التنفيذ المحدد في رمز المصدر.
فيما يلي وصف لكيفية استخدامه.
$ dfa = new DFA ([
' words_file ' => ' ./sensitive_words_sdfdsfvdfs5v56v5dfvdf.txt ' ,
]);ملاحظة خاصة: يتم استخدام اسم الملف للسلسلة المشوهة بشكل خاص لمنع الآخرين من تنزيل ملفات الكلمات الحساسة.
بعد ذلك ، يمكنك استخدام $dfa->containsSensitiveWords($inputText) $outputText = $dfa->replaceWords($inputText) * FALSE TRUE $inputText sensitive_words.txt
إذا كنت لا ترغب في تمكين اكتشاف الكلمات الحساسة ، فقم بالتعليق على الجمل الثلاث التالية في chat.php :
$ dfa = new DFA ([
' words_file ' => ' ./sensitive_words_sdfdsfvdfs5v56v5dfvdf.txt ' ,
]);
$ chat -> set_dfa ( $ dfa );إذا لم يتم تمكين اكتشاف الكلمات الحساسة ، فسيتم إرجاع كل عودة من Openai إلى الواجهة الأمامية في الوقت الفعلي.
إذا تم تمكين اكتشاف الكلمات الحساسة ، [',', '。', ';', '?', '!', '……'] وما إلى ذلك $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 طلب EventSource من خلال 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' ) ;
} ) ;
} اسمحوا لي أن أشرح أن طلب EventSource الأصلي لا يمكن أن يكون سوى طلب GET ، لذلك عندما تتظاهر هنا ، ستضع السؤال مباشرة في معلمة عنوان URL الخاص بـ GET . إذا كنت ترغب في استخدام طلبات POST ، فهناك طريقتان بشكل عام:
POST بتغيير المقدمة والخلفية معًا: [أرسل POST أولاً ثم استخدم POST لطرح أسئلة الواجهة الخلفية ، GET الواجهة الخلفية مفتاحًا GET بناءً على السؤال والوقت.
تغيير الواجهة الأمامية فقط: [ $question = urldecode($_GET['q'] ?? '') POST واحد فقط $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 ميلي ثانية لطباعة محتوى واحد. رمز التنفيذ المحدد كما يلي:
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 طبقة