发布时间:2025-04-10
浏览次数:0
php--gpt--chat-api-webui
有一个由开源的纯 PHP 实现的东西,它可以进行 GPT 流式调用,并且能够在前端实时打印,这就是 webui 。
目录结构
/
├─ /class
│ ├─ Class.GPT.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
目录/文件说明
程序根目录
/class
php类文件目录
/class/Class..php
类,用于处理前端请求,并向 接口提交请求
/class/Class.DFA.php
DFA 类,用于敏感词校验和替换
/class/Class..php
类,用于实时处理 流式返回的数据
/
存放所有前端页面所需的静态文件
//css
存放前端页面所有的 css 文件
//css/chat.css
前端页面聊天样式文件
//css/-.css
代码高亮插件的主题样式文件
//js
存放前端页面所有的 js 文件
//js/chat.js
前端聊天交互 js 代码
//js/.min.js
代码高亮 js 库
//js/.min.js
解析 js 库
/chat.php
前端聊天请求的后端入口文件,在这里引入 php 类文件
/index.html
前端页面 html 代码
/.md
仓库描述文件
/.txt
需要你自行收集敏感词,敏感词文件是一行一个敏感词。你也可以加我微信(同 id)来找我要敏感词。
使用方法
本项目代码,未使用任何框架,也未引入任何第三方后端库。前端引入了代码高亮库以及解析库,且这些库都已下载至项目内。因此,拿到此代码无需进行任何安装,即可直接使用。
唯二要做的就是把你自己的 api key 填进去。
获取到源码之后,需要对 chat.php 进行修改,然后将相应的 api key 填写进去,具体的操作请查看:
$chat = new GPT([
'api_key' => '此处需要填入 openai 的 api key ',
]);
开启敏感词检测功能时,要将敏感词逐个放入.txt 文件里。
开了一个微信群,欢迎入群交流:
原理说明流式接收 的返回数据
后端 Class.php 中使用 curl 向某一目标发起请求。在这个过程中,利用 curl 的 N 来设置回调函数。并且在请求参数里,“” => true 这一设置告知对方开启流式传输。
我们通过 ($ch, N, [$this->, '']) 来进行设置。设置的是使用类的实例化对象 $this-> 的方法。用该方法来处理返回的数据。
在模型每次输出时会返回一个格式字符串为 data: {"id":"","":"","":,"model":"","":[{"delta":{"":""},"index":0,"":null}]},我们所需的回答位于 [0]['delta'][''] 中,并且要做好异常判断,不能直接获取此数据。
另外,实际上由于网络传输的问题,每次函数收到的数据情况并不相同。有时可能只有一条 data: {"key":"value"} 格式的数据;有时可能只有半条;有时可能有多条;还有时可能有 N 条半。
所以我们在 类中增加了 属性来存储无法解析的半条数据。
根据这里的返回数据格式,进行了一些特殊处理,具体代码如下:
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 表示将 JSON 字符串解码为关联数组,false 表示解码为对象。如果 JSON 数据格式不正确,该函数可能会返回 false 或者抛出一个错误。TRUE);
if(is_array($result)){
$this->end('openai 请求错误:'.json_encode($result));
return strlen($data);
}
/*
此步骤仅为针对 openai 接口的情况。
每次触发回调函数的时候,里面会有很多条 data 数据,这些数据需要进行分割。
如某次收到 $data 如下所示:
以下是使用的内容。
最后两条一般是这样的:
且数据为[DONE]
依据上述 openai 的数据格式,其分割步骤具体如下:
*/
// 0、把上次缓冲区内数据拼接上本次的data
$buffer = $this->data_buffer.$data;
把所有的'data: {'替换为'{ ',把'data: ['换成'['。
$buffer 进行了字符串替换操作,将某些字符串替换为其他字符串。具体来说,是使用 str_replace 函数来执行这个替换动作,它会在指定的字符串中查找特定的字符串,并将其替换为指定的新字符串。'data: {', '{', $buffer);
$buffer = str_replace('data: [', '[', $buffer);
把所有的“}\n\n{”替换为“}[br]{”,并且把“}\n\n[”替换为“}[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);
循环处理每一行,对于每一行都要进行处理。其中,对于最后一行,需要判断它是否是完整的 json。
$line_c 的值等于数组 $lines 的元素个数。
foreach($lines as $li=>$line){
if(trim($line) == '[DONE]'){
//数据传输结束
$this->data_buffer = '';
$this->counter = 0;
$this->sensitive_check();
$this->end();
break;
}
$line_data 被用于将 trim($line) 的结果进行 json 解码。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'使用 JSON_UNESCAPED_UNICODE 和 JSON_PRETTY_PRINT 选项,将数据写入文件并追加内容,换行。
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 算法来达成敏感词检测的目的。依据相关解释,“DFA”的意思是“确定性有限自动机”( ) ,(确定有限自动机过滤器)一般是指一种被用于文本处理与匹配的算法。
GPT4 编写了 Class.DFA.php 类代码,其具体实现代码可在源码中查看。
这里为你介绍其使用方法,创建一个 DFA 实例时需要将敏感词文件的路径进行传入。
$dfa = new DFA([
'words_file' => './sensitive_words.txt',
]);
之后能够用 $dfa->ds($) 去对 $ 是否包含敏感词进行判断,其返回值为 TRUE 或 FALSE 的布尔值。同时,也可以用 $ = $dfa->($) 来进行敏感词的替换操作,在.txt 中指定的所有敏感词都会被替换成三个*号。
不想开启敏感词检测的话,只需将 chat.php 中的以下三句进行注释处理就可以了。
$dfa = new DFA([
'words_file' => './sensitive_words.txt',
]);
$chat->set_dfa($dfa);
如果没有开启敏感词检测,那么每次的返回会实时被返回给前端。
如果开启了敏感词检测,就会查找返回中的换行符和停顿符号sublime text 3 php插件,比如[',', '。', ';', '?', '!', '……']等,以此来进行分句。每一句都会使用 $ = $dfa->($) 来替换敏感词,然后将整句返回给前端。
开启敏感词后sublime text 3 php插件,加载敏感词文件需要花费时间。每次进行检测时,是逐句进行检测,而非逐词检测,这也会使得返回的速度变慢。
自用的话,可以不开启敏感词检测。部署出去给其他人用的话,为了保护域名安全和自身安全,最好开启敏感词检测。
流式返回给前端
直接看 chat.php 的注释会更清楚:
/*
以下几行注释由 GPT4 生成
*/
这行代码的作用是关闭输出缓冲。一旦关闭,脚本的输出就会立刻被发送到浏览器,而不会去等待缓冲区被填满或者脚本执行结束。
ini_set('output_buffering', 'off');
这行代码让 zlib 压缩被禁用。一般来讲,启用 zlib 压缩能够让发送到浏览器的数据量变小。然而对于服务器发送事件而言,实时性是更为重要的,所以就需要把压缩给禁用掉。
ini_set('zlib.output_compression', false);
这行代码通过循环来清空所有当前处于激活状态的输出缓冲区。ob_end_flush 这个函数能够刷新并关闭最里面的输出缓冲区,@符号的作用是抑制可能会出现的错误或者警告。
while (@ob_end_flush()) {}
此类型是服务器发送事件(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();
这里定义了我们自己使用的一个数据格式,此格式中只放置了 time 以及另一个内容。无需解释,大家都能明白,time 指的是时间,而另一个内容就是我们要返回给前端的东西。
请注意,在全部传输完毕后,我们需要关闭连接,具体可通过以下代码来实现:
echo 'retry: 86400000'.PHP_EOL; // 告诉前端如果发生错误,隔多久之后才轮询一次
echo 'event: close'.PHP_EOL; // 告诉前端,结束了,该说再见了
echo 'data: Connection closed'.PHP_EOL.PHP_EOL; // 告诉前端,连接已关闭
flush();
前端的 js 使用 const 并通过 const = new (url) 来开启一个请求。
之后服务器向前端发送数据,数据格式为 {"kev1":"","kev2":""} 。前端能够在某个回调事件的 event.data 中获取到 {"kev1":"","kev2":""} 这种字符串形式的 json 数据。接着,通过 JSON.parse(event.data) 操作,就可以得到 js 对象。
具体代码在 函数中,如下所示:
function getAnswer(inputValue){
inputValue 被替换为 inputValue 进行了替换操作,即将 inputValue 中的某些内容替换掉了。具体来说,是用某种方式对 inputValue 进行了处理,使其内容发生了改变,原本的 inputValue 被新的经过替换后的内容所取代。'+', '{[$add$]}');
const url = "./chat.php?q="+inputValue;
const eventSource = new EventSource(url);
eventSource 为该对象增添了事件监听的设置。"open", (event) => {
console.log("连接已建立", JSON.stringify(event));
});
eventSource.addEventListener("message", (event) => {
在控制台输出“接收数据:”以及事件 event 的相关信息。
try {
var result = JSON.parse(event.data);
if如果有 result.time 并且有 result.content ,那么就会执行相应的操作。如果只有 result.time 而没有 result.content ,则可能不会执行特定操作。如果只有 result.content 而没有 result.time ,也可能不会执行特定操作。只有当 result.time 和 result.content 都存在时,才会执行相关操作。
answerWords 会将 result.content 进行添加操作。
将“contentIdx +=”改写为:“contentIdx 进行增加操作。” 或 “contentIdx 增加了相应的值。” 或 “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');
});
}
打字机效果
对于后端返回的所有回复内容,我们需要用打字机形式打印出来。
最初的方案是
function typingWords(){
if如果 contentEnd 并且 contentIdx 等于 typingIdx,那么……
清除定时器 typingTimer。
answerContent = '';
answerWords = [];
answers = [];
qaIdx += 1;
typingIdx = 0;
contentIdx = 0;
contentEnd = false;
lastWord = '';
lastLastWord = '';
input.disabled = false;
sendButton 被禁用了。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);
}
constcontent 等于 answerWords 中的 typingIdx 所对应的元素。
if(content.indexOf('`') != -1){
if(content.indexOf('```') != -1){
codeStart 不等于!codeStart 。
}else if(content.indexOf('``') != -1(lastWord + content).indexOf( 这个表达式中,首先获取 lastWord 与 content 拼接后的字符串,然后在这个字符串中查找特定的目标位置或内容。它通过这种方式来确定特定元素或信息在组合字符串中的位置情况。'```') != -1){
codeStart = !codeStart;
}else if(content.indexOf('`') != -1(lastLastWord + lastWord + content).indexOf() ,即寻找 (lastLastWord + lastWord + content) 中特定元素的位置。'```') != -1){
codeStart = !codeStart;
}
}
lastLastWord = lastWord;
lastWord = content;
answerContent += content;
answers[qaIdx]的 innerHTML 等于 marked.parse(answerContent 加上 (codeStart 是否存在) ?'\n\n```':''));
typingIdx += 1;
typing = false;
}
其它
若要查看更多其它细节,可查看代码。若对代码存在疑问,可添加我的微信,微信账号与 id 相同。
BSD 2-
如有侵权请联系删除!
Copyright © 2023 江苏优软数字科技有限公司 All Rights Reserved.正版sublime text、Codejock、IntelliJ IDEA、sketch、Mestrenova、DNAstar服务提供商
13262879759
微信二维码