V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
爱意满满的作品展示区。
qiayue
V2EX  ›  分享创造

开源了一个不使用任何后端框架纯 PHP 实现流式调用 OpenAI gpt 接口的项目

  •  
  •   qiayue · 2023-03-24 13:52:53 +08:00 · 3303 次点击
    这是一个创建于 371 天前的主题,其中的信息可能已经有所发展或是发生改变。

    php-openai-gpt-stream-chat-api-webui

    @qiayue 开源的 纯 PHP 实现 GPT 流式调用和前端实时打印 webui

    仓库地址: https://github.com/qiayue/php-openai-gpt-stream-chat-api-webui

    演示站点: https://aiwanjia.cn/

    提前说明下,本文的价值比开源的代码更高,因为代码只是本文的实现而已。

    目录结构

    /
    ├─ /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
    
    目录 /文件 说明
    / 程序根目录
    /class php 类文件目录
    /class/Class.ChatGPT.php ChatGPT 类,用于处理前端请求,并向 OpenAI 接口提交请求
    /class/Class.DFA.php DFA 类,用于敏感词校验和替换
    /class/Class.StreamHandler.php StreamHandler 类,用于实时处理 OpenAI 流式返回的数据
    /static 存放所有前端页面所需的静态文件
    /static/css 存放前端页面所有的 css 文件
    /static/css/chat.css 前端页面聊天样式文件
    /static/css/monokai-sublime.css highlight 代码高亮插件的主题样式文件
    /static/js 存放前端页面所有的 js 文件
    /static/js/chat.js 前端聊天交互 js 代码
    /static/js/highlight.min.js 代码高亮 js 库
    /static/js/marked.min.js markdown 解析 js 库
    /chat.php 前端聊天请求的后端入口文件,在这里引入 php 类文件
    /index.html 前端页面 html 代码
    /README. md 仓库描述文件
    /sensitive_words.txt 敏感词文件,一行一个敏感词,需要你自己收集敏感词,也可以加我微信(同 GitHub id )找我要

    使用方法

    本项目代码,没有使用任何框架,也没有引入任何第三方后端库,前端引入了代码高亮库 highlight 和 markdown 解析库 marked 都已经下载项目内了,所以拿到代码不用任何安装即可直接使用。

    唯二要做的就是把你自己的 api key 填进去。

    获取源码后,修改 chat.php ,填写 OpenAI 的 api key 进去,具体请见:

    $chat = new ChatGPT([
        'api_key' => '此处需要填入 openai 的 api key ',
    ]);
    

    如果开启敏感词检测功能,需要把敏感词一行一个放入 sensitive_words_sdfdsfvdfs5v56v5dfvdf.txt 文件中。

    开了一个微信群,欢迎入群交流:

    释疑微信群

    原理说明

    流式接收 OpenAI 的返回数据

    后端 Class.ChatGPT.php 中用 curl 向 OpenAI 发起请求,使用 curl 的 CURLOPT_WRITEFUNCTION 设置回调函数,同时请求参数里 'stream' => true 告诉 OpenAI 开启流式传输。

    我们通过 curl_setopt($ch, CURLOPT_WRITEFUNCTION, [$this->streamHandler, 'callback']); 设置使用 StreamHandler 类的实例化对象 $this->streamHandlercallback 方法来处理 OpenAI 返回的数据。

    OpenAI 会在模型每次输出时返回 data: {"id":"","object":"","created":1679616251,"model":"","choices":[{"delta":{"content":""},"index":0,"finish_reason":null}]} 格式字符串,其中我们需要的回答就在 choices[0]['delta']['content'] 里,当然我们也要做好异常判断,不能直接这样获取数据。

    另外,实际因为网络传输问题,每次 callback 函数收到的数据并不一定只有一条 data: {"key":"value"} 格式的数据,有可能只有半条,也有可能有多条,还有可能有 N 条半。

    所以我们在 StreamHandler 类中增加了 data_buffer 属性来存储无法解析的半条数据。

    这里根据 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}]}\n\ndata: {"id":"chatcmpl-6wimHHBt4hKFHEpFnNT2ryUeuRRJC","object":"chat.completion.chunk","created":1679453169,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"以下"},"index":0,"finish_reason":null}]}\n\ndata: {"id":"chatcmpl-6wimHHBt4hKFHEpFnNT2ryUeuRRJC","object":"chat.completion.chunk","created":1679453169,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"是"},"index":0,"finish_reason":null}]}\n\ndata: {"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"}]}\n\ndata: [DONE]
    
                根据以上 openai 的数据格式,分割步骤如下:
            */
    
            // 0 、把上次缓冲区内数据拼接上本次的 data
            $buffer = $this->data_buffer.$data;
    
            // 1 、把所有的 'data: {' 替换为 '{' ,'data: [' 换成 '['
            $buffer = str_replace('data: {', '{', $buffer);
            $buffer = str_replace('data: [', '[', $buffer);
    
            // 2 、把所有的 '}\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);
    
            // 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) 来判断 $inputText 是否包含敏感词,返回值是 TRUEFALSE 的布尔值,也可以用 $outputText = $dfa->replaceWords($inputText) 来进行敏感词替换,所有在 sensitive_words.txt 中指定的敏感词都会被替换为三个*号。

    如果不想开启敏感词检测,把 chat.php 中的以下三句注释掉即可:

    $dfa = new DFA([
        'words_file' => './sensitive_words_sdfdsfvdfs5v56v5dfvdf.txt',
    ]);
    $chat->set_dfa($dfa);
    

    如果没有开启敏感词检测,那么每次 OpenAI 的返回都会实时返回给前端。

    如果开启了敏感词检测,会查找 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();
    

    这里我们定义了我们自己使用的一个数据格式,里边只放了 time 和 content ,不用解释都懂,time 是时间,content 就是我们要返回给前端的内容。

    注意,回答全部传输完毕后,我们需要关闭连接,可以用以下代码:

    echo 'retry: 86400000'.PHP_EOL; // 告诉前端如果发生错误,隔多久之后才轮询一次
    echo 'event: close'.PHP_EOL; // 告诉前端,结束了,该说再见了
    echo 'data: Connection closed'.PHP_EOL.PHP_EOL; // 告诉前端,连接已关闭
    flush();
    

    EventSource

    前端 js 通过 const eventSource = new EventSource(url); 开启一个 EventSource 请求。

    之后服务器按照 data: {"kev1":"value1","kev2":"value2"} 格式向前端发送数据,前端就可以在 EventSource 的 message 回调事件中的 event.data 里获取 {"kev1":"value1","kev2":"value2"} 字符串形式 json 数据,再通过 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 请求,所以这里演示时,直接把提问放到 GETURL 参数里了。 如果要想用 POST 请求,一般有两种办法:

    1. 前后端一起改: [先发 POST 后发 GET ] 用 POST 向后端提问,后端根据提问和时间生成一个唯一 key 随着 POST 请求返回给前端,前端拿到后,再发起一个 GET 请求,在参数里携带问题 key ,获取回答,这种方式需要修改后端代码;

    2. 只改前端: [只发一个 POST 请求] 后端代码不用大改,只需要把 chat.php$question = urldecode($_GET['q'] ?? '') 改为 $question = urldecode($_POST['q'] ?? '') 即可,但是前端需要改造,不能用原生 EventSource 请求,需要用 fetch ,设置流式接收,具体可见下方 GPT4 给出的代码示例。

    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 }

    打字机效果

    对于后端返回的所有回复内容,我们需要用打字机形式打印出来。

    最初的方案是

    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?'\n\n```':''));
    
        typingIdx += 1;
        typing = false;
    }
    

    其它

    更多其它细节请看代码,如果对代码有疑问的,请加我微信(同 GitHub id )

    License

    BSD 2-Clause

    14 条回复    2023-03-25 08:54:49 +08:00
    qiayue
        1
    qiayue  
    OP
       2023-03-24 14:15:26 +08:00
    原文 [打字机效果] 没写完,这里补上:
    最初的方案是每次接收到后端的返回后就立即显示到页面里,后来发现这样速度太快了,眨眼就显示完了,没有打印机效果。 所以后来的方案就改成了用定时器实现定时打印,那么就需要把收到的先放进数组里缓存起来,然后定时每 50 毫秒执行一次,打印一个内容出来。 具体实现代码如下:
    qiayue
        2
    qiayue  
    OP
       2023-03-24 14:16:26 +08:00
    原文最后补充

    ### 代码渲染

    如果严格按照输出什么打印什么的话,那么当正在打印一段代码,需要等到代码全部打完,才能被格式化为代码块,才能高亮显示代码。
    那这个体验也太差了。
    有什么办法能够解决这个问题呢?
    答案就在问题里,既然是因为代码块有开始标记没有结束标记,那就我们给他补全结束标记就好了,直到真的结束标记来了,才不需要补全。

    具体的实现就是下面几行代码:

    ```js
    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?'\n\n```':''));
    ```
    meta2048
        3
    meta2048  
       2023-03-24 15:55:08 +08:00
    这个必须得支持一下
    go522000
        4
    go522000  
       2023-03-24 16:02:52 +08:00
    非常清晰,感谢分享。
    brader
        5
    brader  
       2023-03-24 16:14:57 +08:00
    有几个建议仅供参考:
    一、看了你 demo 站,没有逐字输出效果,初步怀疑是你没有关闭 nginx 缓冲区造成的。
    二、你的 messages 没有复传完整的上下文对话数组,导致 chatgpt 失去了连续对话能力。
    三、你虽然实现了 EventSource 消息的解析,但解析代码和传输数据强耦合在了一起,EventSource 有其标准的数据格式,可参考文献 https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events

    下面是我在项目实际应用中使用 Guzzle 包简单实现的一个方法封装 demo ,它不是完整的代码,有部分初始化工作在其他地方,仅供参考:
    ```
    /**
    * 创建聊天(以流的形式直接输出)
    *
    * @param array $messages 内容 [{"role":"user","content":"你好"},{"role":"assistant","content":"你好!我是 AI 助手,请问你有什么需要帮助的吗?"},{"role":"user","content":"怎么称呼你"}]
    */
    public function createChatCompletionStream($messages = [])
    {
    if (empty($messages)) {
    exit();
    }

    try {
    $response = $this->guzzle->request("POST", '/v1/chat/completions', [
    'json' => [
    'model' => 'gpt-3.5-turbo',
    'messages' => $messages,
    'stream' => true,
    ],
    'stream' => true,
    ]);

    $body = $response->getBody();
    $buffer = '';
    while (!$body->eof()) {
    $buffer .= $body->read(128);
    // 这里使用 while 是因为读取 n 个字节有可能同时读出 n 条 EventSource 消息
    while (($pos = strpos($buffer, "\n\n")) !== false) {
    $msg = substr($buffer, 0, $pos); // 一条 event 消息
    $buffer = substr($buffer, $pos + 2); // 去除已被解析的部分

    if (substr($msg, 0, 6) === 'data: ') { // 只解析了 data ,实际的 EventSource 还有 event 、id 、retry
    $obj = json_decode(substr($msg, 6));
    if (isset($obj->choices[0]->delta->content)) {
    echo $obj->choices[0]->delta->content;
    ob_flush();
    flush();
    }
    }
    }
    }
    exit();

    } catch (GuzzleException $e) {
    Log::error($e->getMessage());
    return response('请求失败,请稍后重试', 500);
    }
    }
    ```
    brader
        6
    brader  
       2023-03-24 16:19:32 +08:00
    抱歉,上面的代码格式无法保持,大家自己粘贴了格式化。在 V2EX 回复我不清楚如何使用 md 语法,这每次让我很苦恼
    uplee
        7
    uplee  
       2023-03-24 16:22:11 +08:00
    现在 PHP 不用包管理是缺点吧,我用的这个
    https://github.com/orhanerday/open-ai
    wizzer
        8
    wizzer  
       2023-03-24 16:26:35 +08:00
    不错
    qiayue
        9
    qiayue  
    OP
       2023-03-24 16:26:53 +08:00
    @uplee 我也在用这个,但是想自己搞清楚整个传输流程,所以就去研究了一下,写了点 demo ,写完后发现这里细节还挺多的,就想着写篇文章介绍下,既然写了文章当然就要配套的代码,所以才想着搞成一个稍微完整点的项目开源出来的。
    brader
        10
    brader  
       2023-03-24 16:28:25 +08:00   ❤️ 1
    @uplee 起初我也想用这个包,很遗憾,作为一个工具,它对旧项目不那么友好,PHP 7.4+的要求让我望而却步,我仅需要使用到 chatgpt 的几个 API 而已,而且他的 API 非常易接入,就自己实现了
    JoeyWang321
        11
    JoeyWang321  
       2023-03-24 16:28:36 +08:00
    你好,你的这个演示 demo 好像不支持上下文,是我的测试有问题吗
    qiayue
        12
    qiayue  
    OP
       2023-03-24 16:29:59 +08:00
    @brader 感谢,demo 站开启了敏感词校验,所以不是逐字返回给前端,而是逐行返回给前端的,所以看起来是一顿一顿的输出。连续对话能力特意没做的,这个项目暂时只是为了让大家了解清楚流式传输的原理。数据格式的确是我的问题,没按照标准来。
    qiayue
        13
    qiayue  
    OP
       2023-03-24 16:30:35 +08:00
    @JoeyWang321 特意没做上下文功能。
    qiayue
        14
    qiayue  
    OP
       2023-03-25 08:54:49 +08:00
    @brader 关于你说的没有打字机效果,我改了下,之前是逐句检测,逐句输出,现在改成逐句检测,但是如果不包含敏感词则逐字输出,这样大多数情况下,都会有打字机效果了。
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   我们的愿景   ·   实用小工具   ·   2651 人在线   最高记录 6543   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 28ms · UTC 15:18 · PVG 23:18 · LAX 08:18 · JFK 11:18
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.